Bouncing balls in a heptagon · qwen3.6-27b
Show 20 numbered balls (1 to 20) dropping from the center of a heptagon that spins 360 degrees every 5 seconds. The balls are affected by gravity and friction, collide with each other, and bounce realistically off the rotating walls; show each ball's spin via the rotation of its number. Implement all collision detection yourself.
transcript · 10 messages
Show 20 numbered balls (1 to 20) dropping from the center of a heptagon that spins 360 degrees every 5 seconds. The balls are affected by gravity and friction, collide with each other, and bounce realistically off the rotating walls; show each ball's spin via the rotation of its number. Implement all collision detection yourself.
thinking
The user wants a single-page web app with: 1. A heptagon (7-sided polygon) that spins 360° every 5 seconds 2. 20 numbered balls (1-20) that drop from the center 3. Gravity and friction physics 4. Ball-to-ball collisions 5. Ball-to-wall collisions (bouncing off rotating walls) 6. Each ball's number rotates to show spin 7. All collision detection implemented from scratch 8. Everything in one index.html file, no external resources Let me build this step by step. I'll use a canvas element and implement the physics manually. Key physics considerations: - Gravity: constant downward acceleration - Friction: velocity damping - Ball-to-ball: elastic collision detection using distance check, resolve with momentum conservation - Rotating heptagon walls: need to transform wall positions based on rotation, then detect ball-wall collisions with proper reflection - Ball spin: track angular velocity, update rotation of each ball's number Let me write this out.
/workspace/index.html (12788 bytes)Successfully wrote 12788 bytes to /workspace/index.html
thinking
Let me verify the file was written correctly and check its content.
{"command":"wc -l /workspace/index.html && head -5 /workspace/index.html && echo \"---\" && tail -5 /workspace/index.html"}480 /workspace/index.html <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> --- requestAnimationFrame(loop); </script> </body> </html>
thinking
The file is created. Let me review it more carefully to make sure everything works well. Let me re-read it to check for any issues. Key things to verify: 1. Heptagon rotation - correct 2. Ball physics with gravity, friction - correct 3. Ball-ball collisions - implemented with elastic collision resolution 4. Ball-wall collisions - using line segment distance, wall velocity from rotation 5. Ball spin visualization - numbers rotate based on angular velocity 6. No external dependencies - all inline Let me also double-check a few details on the physics to make sure it's solid. Actually, I notice I should also think about the wall velocity calculation more carefully. The wall velocity at a point due to rotation is v = ω × r, which in 2D means vx = -ω * (y - cy), vy = ω * (x - cx). That looks correct. Let me also verify the substeps are applied with the final heptAngle for rendering. Actually, in the physics loop, I'm computing verts for each substep using the same heptAngle (which advances once per frame). This is a simplification - for more accuracy I should interpolate, but it should work fine visually. I think there might be one issue: the Wall velocity calculation. Let me reconsider. `wallVx = -HEPT_OMEGA * contactDy` means `wallVx = -ω * (ball.y - CY)`. This is correct: when HEPT_OMEGA is positive (counterclockwise rotation), a point above center (contactDy < 0) has a positive wallVx (moving right). Wait actually, let me reconsider. The contact point relative to center is `(ball.x - CX, ball.y - CY)`. In 2D cross product for angular velocity: - v = ω × r - For CCW rotation (ω pointing out of page), the velocity of a point at position r is rotated 90° CCW from r - So vx = -ω * dy, vy = ω * dx That's what I have. Good. One more thing - I want to make sure the spin visualization is clear. Each ball's number rotates based on its spin angular velocity. When balls collide or hit walls, they gain spin. This should look nice. I think this is solid. Let me verify the complete file once more by reading it.
{"path":"/workspace/index.html"}<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Spinning Heptagon Ball Drop</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0a0a1a; display: flex; justify-content: center; align-items: center; height: 100vh; overflow: hidden; font-family: 'Segoe UI', Arial, sans-serif; } canvas { border-radius: 8px; box-shadow: 0 0 40px rgba(100, 140, 255, 0.15); } #info { position: absolute; top: 16px; left: 50%; transform: translateX(-50%); color: #8899cc; font-size: 14px; letter-spacing: 1px; text-align: center; pointer-events: none; } </style> </head> <body> <div id="info">20 balls · spinning heptagon · gravity + collisions</div> <canvas id="c"></canvas> <script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); // --- Sizing --- const SIZE = Math.min(window.innerWidth - 20, window.innerHeight - 20, 800); canvas.width = SIZE; canvas.height = SIZE; const CX = SIZE / 2; const CY = SIZE / 2; const HEPT_RADIUS = SIZE * 0.44; // --- Physics constants --- const GRAVITY = 400; // pixels/s² const FRICTION = 0.998; // per-frame velocity damping const BALL_RADIUS = SIZE * 0.025; const RESTITUTION = 0.75; // bounciness for walls const BALL_RESTITUTION = 0.9; // bounciness for ball-ball const SPIN_FRICTION = 0.97; // spin damping const SPIN_TRANSFER = 0.03; // how much contact spin is imparted const DT = 1 / 60; // --- Heptagon --- const SIDES = 7; const HEPT_PERIOD = 5; // seconds for 360° const HEPT_OMEGA = (2 * Math.PI) / HEPT_PERIOD; let heptAngle = 0; function heptagonVertices(angle) { const verts = []; for (let i = 0; i < SIDES; i++) { const a = angle + (i * 2 * Math.PI / SIDES) - Math.PI / 2; verts.push({ x: CX + HEPT_RADIUS * Math.cos(a), y: CY + HEPT_RADIUS * Math.sin(a) }); } return verts; } // --- Colors for balls --- function ballColor(i) { const palettes = [ ['#ff6b6b','#ee5a24','#ff9f43','#feca57'], ['#48dbfb','#0abde3','#54a0ff','#5f27cd'], ['#1dd1a1','#10ac84','#00d2d3','#54a0ff'], ['#ff9ff3','#f368e0','#ee5a9c','#c44569'], ['#f8a5c2','#f19066','#e77f67','#cf6a87'] ]; const p = palettes[i % palettes.length]; return p[i % p.length]; } // --- Ball class --- class Ball { constructor(n, x, y) { this.n = n; this.x = x; this.y = y; this.vx = (Math.random() - 0.5) * 60; this.vy = (Math.random() - 0.5) * 60; this.r = BALL_RADIUS; this.mass = 1; this.angle = Math.random() * Math.PI * 2; this.spin = (Math.random() - 0.5) * 3; // angular velocity rad/s this.color = ballColor(n - 1); this.trail = []; } update(dt) { this.vy += GRAVITY * dt; this.vx *= FRICTION; this.vy *= FRICTION; this.x += this.vx * dt; this.y += this.vy * dt; this.angle += this.spin * dt; this.spin *= SPIN_FRICTION; // Trail this.trail.push({ x: this.x, y: this.y, a: 1 }); if (this.trail.length > 8) this.trail.shift(); for (let t of this.trail) t.a *= 0.85; } } // --- Create balls --- const balls = []; for (let i = 1; i <= 20; i++) { // Staggered drop from center area const jx = (Math.random() - 0.5) * 15; const jy = (Math.random() - 0.5) * 15; balls.push(new Ball(i, CX + jx, CY - 80 + jy)); } // --- Collision detection: point to line segment --- function pointToSegment(px, py, ax, ay, bx, by) { const dx = bx - ax, dy = by - ay; const len2 = dx * dx + dy * dy; let t = ((px - ax) * dx + (py - ay) * dy) / len2; t = Math.max(0, Math.min(1, t)); const cx = ax + t * dx; const cy = ay + t * dy; const dist = Math.sqrt((px - cx) ** 2 + (py - cy) ** 2); return { dist, cx, cy, nx: px - cx, ny: py - cy }; } // --- Wall collision --- function resolveWallCollisions(ball, verts) { for (let i = 0; i < verts.length; i++) { const a = verts[i]; const b = verts[(i + 1) % verts.length]; // Wall inward normal (toward center) const wallDx = b.x - a.x; const wallDy = b.y - a.y; const wallLen = Math.sqrt(wallDx * wallDx + wallDy * wallDy); const wallNx = -wallDy / wallLen; const wallNy = wallDx / wallLen; // Check which side center is on const toCenterDx = CX - a.x; const toCenterDy = CY - a.y; const dotCenter = toCenterDx * wallNx + toCenterDy * wallNy; const sign = dotCenter >= 0 ? 1 : -1; const invNx = wallNx * sign; const invNy = wallNy * sign; // Distance from ball center to wall line const dist = (ball.x - a.x) * invNx + (ball.y - a.y) * invNy; if (dist < ball.r) { // Push ball out const overlap = ball.r - dist; ball.x += invNx * overlap; ball.y += invNy * overlap; // Wall velocity at contact point (due to rotation) const contactDx = ball.x - CX; const contactDy = ball.y - CY; const wallVx = -HEPT_OMEGA * contactDy; const wallVy = HEPT_OMEGA * contactDx; // Relative velocity const relVx = ball.vx - wallVx; const relVy = ball.vy - wallVy; // Velocity along normal const vn = relVx * invNx + relVy * invNy; if (vn < 0) { // Reflect ball.vx -= (1 + RESTITUTION) * vn * invNx; ball.vy -= (1 + RESTITUTION) * vn * invNy; // Tangential component for spin const tx = -invNy, ty = invNx; const vt = relVx * tx + relVy * ty; ball.spin += SPIN_TRANSFER * vt / ball.r; } } } // Safety: keep ball inside heptagon let inside = isInsideHeptagon(ball.x, ball.y, verts); if (!inside) { // Pull back toward center ball.x = CX + (ball.x - CX) * 0.95; ball.y = CY + (ball.y - CY) * 0.95; ball.vx *= 0.5; ball.vy *= 0.5; } } function isInsideHeptagon(px, py, verts) { for (let i = 0; i < verts.length; i++) { const a = verts[i]; const b = verts[(i + 1) % verts.length]; const wallDx = b.x - a.x; const wallDy = b.y - a.y; const wallLen = Math.sqrt(wallDx * wallDx + wallDy * wallDy); const wallNx = -wallDy / wallLen; const wallNy = wallDx / wallLen; const toCenterDx = CX - a.x; const toCenterDy = CY - a.y; const dotCenter = toCenterDx * wallNx + toCenterDy * wallNy; const sign = dotCenter >= 0 ? 1 : -1; const invNx = wallNx * sign; const invNy = wallNy * sign; const dist = (px - a.x) * invNx + (py - a.y) * invNy; if (dist < -2) return false; } return true; } // --- Ball-ball collision --- function resolveBallCollisions(a, b) { const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy); const minDist = a.r + b.r; if (dist < minDist && dist > 0.001) { // Normal const nx = dx / dist; const ny = dy / dist; // Separate const overlap = (minDist - dist) / 2; a.x -= nx * overlap; a.y -= ny * overlap; b.x += nx * overlap; b.y += ny * overlap; // Relative velocity const relVx = a.vx - b.vx; const relVy = a.vy - b.vy; const vn = relVx * nx + relVy * ny; if (vn > 0) { const impulse = vn * (1 + BALL_RESTITUTION) / (a.mass + b.mass); a.vx -= impulse * b.mass * nx; a.vy -= impulse * b.mass * ny; b.vx += impulse * a.mass * nx; b.vy += impulse * a.mass * ny; // Spin from tangential contact const tx = -ny, ty = nx; const vt = relVx * tx + relVy * ty; a.spin -= SPIN_TRANSFER * vt / a.r; b.spin += SPIN_TRANSFER * vt / b.r; } } } // --- Rendering --- function drawHeptagon(verts, angle) { // Glow effect ctx.save(); ctx.shadowColor = 'rgba(80, 120, 255, 0.4)'; ctx.shadowBlur = 20; ctx.beginPath(); ctx.moveTo(verts[0].x, verts[0].y); for (let i = 1; i < verts.length; i++) { ctx.lineTo(verts[i].x, verts[i].y); } ctx.closePath(); ctx.strokeStyle = '#3a5adf'; ctx.lineWidth = 3; ctx.stroke(); ctx.shadowBlur = 0; ctx.restore(); // Inner subtle fill ctx.beginPath(); ctx.moveTo(verts[0].x, verts[0].y); for (let i = 1; i < verts.length; i++) { ctx.lineTo(verts[i].x, verts[i].y); } ctx.closePath(); ctx.fillStyle = 'rgba(20, 30, 70, 0.25)'; ctx.fill(); // Wall edge highlights for (let i = 0; i < verts.length; i++) { const a = verts[i]; const b = verts[(i + 1) % verts.length]; const grad = ctx.createLinearGradient(a.x, a.y, b.x, b.y); grad.addColorStop(0, 'rgba(100, 150, 255, 0.6)'); grad.addColorStop(0.5, 'rgba(180, 210, 255, 0.9)'); grad.addColorStop(1, 'rgba(100, 150, 255, 0.6)'); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.strokeStyle = grad; ctx.lineWidth = 2.5; ctx.stroke(); } // Vertices dots for (const v of verts) { ctx.beginPath(); ctx.arc(v.x, v.y, 3, 0, Math.PI * 2); ctx.fillStyle = '#7799ff'; ctx.fill(); } } function drawBall(ball) { // Trail for (let i = 0; i < ball.trail.length; i++) { const t = ball.trail[i]; ctx.beginPath(); ctx.arc(t.x, t.y, ball.r * 0.4, 0, Math.PI * 2); ctx.fillStyle = ball.color + Math.floor(t.a * 40).toString(16).padStart(2, '0'); ctx.fill(); } ctx.save(); ctx.translate(ball.x, ball.y); // Ball body const bodyGrad = ctx.createRadialGradient( -ball.r * 0.3, -ball.r * 0.3, ball.r * 0.1, 0, 0, ball.r ); bodyGrad.addColorStop(0, lighten(ball.color, 60)); bodyGrad.addColorStop(0.7, ball.color); bodyGrad.addColorStop(1, darken(ball.color, 40)); ctx.beginPath(); ctx.arc(0, 0, ball.r, 0, Math.PI * 2); ctx.fillStyle = bodyGrad; ctx.fill(); // Outline ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1; ctx.stroke(); // Number with rotation (shows spin) ctx.rotate(ball.angle); ctx.font = `bold ${ball.r * 1.2}px 'Segoe UI', Arial, sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#fff'; ctx.shadowColor = 'rgba(0,0,0,0.5)'; ctx.shadowBlur = 2; ctx.fillText(ball.n, 0, 1); ctx.shadowBlur = 0; ctx.restore(); } function lighten(hex, amount) { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgb(${Math.min(255, r + amount)},${Math.min(255, g + amount)},${Math.min(255, b + amount)})`; } function darken(hex, amount) { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return `rgb(${Math.max(0, r - amount)},${Math.max(0, g - amount)},${Math.max(0, b - amount)})`; } // --- Background stars --- const stars = []; for (let i = 0; i < 80; i++) { stars.push({ x: Math.random() * SIZE, y: Math.random() * SIZE, r: Math.random() * 1.5 + 0.3, a: Math.random() * 0.5 + 0.1, pulse: Math.random() * Math.PI * 2 }); } // --- Main loop --- let lastTime = performance.now(); let frameCount = 0; function loop(now) { const elapsed = Math.min((now - lastTime) / 1000, 0.05); lastTime = now; // Update heptagon heptAngle += HEPT_OMEGA * elapsed; // Physics substeps for stability const substeps = 3; const subDt = elapsed / substeps; for (let s = 0; s < substeps; s++) { const verts = heptagonVertices(heptAngle); for (const ball of balls) { ball.update(subDt); resolveWallCollisions(ball, verts); } // Ball-ball collisions (all pairs) for (let i = 0; i < balls.length; i++) { for (let j = i + 1; j < balls.length; j++) { resolveBallCollisions(balls[i], balls[j]); } } } // --- Render --- ctx.clearRect(0, 0, SIZE, SIZE); // Background const bgGrad = ctx.createRadialGradient(CX, CY, 0, CX, CY, SIZE * 0.7); bgGrad.addColorStop(0, '#10102a'); bgGrad.addColorStop(1, '#050510'); ctx.fillStyle = bgGrad; ctx.fillRect(0, 0, SIZE, SIZE); // Stars for (const star of stars) { star.pulse += 0.02; const flicker = star.a * (0.7 + 0.3 * Math.sin(star.pulse)); ctx.beginPath(); ctx.arc(star.x, star.y, star.r, 0, Math.PI * 2); ctx.fillStyle = `rgba(200, 210, 255, ${flicker})`; ctx.fill(); } // Center marker (subtle) ctx.beginPath(); ctx.arc(CX, CY, 3, 0, Math.PI * 2); ctx.fillStyle = 'rgba(100, 140, 255, 0.3)'; ctx.fill(); // Heptagon const verts = heptagonVertices(heptAngle); drawHeptagon(verts, heptAngle); // Balls (sorted by y for slight depth feel) const sortedBalls = [...balls].sort((a, b) => a.y - b.y); for (const ball of sortedBalls) { drawBall(ball); } // FPS counter (debug, subtle) frameCount++; if (frameCount % 60 === 0) { // Could display FPS here } requestAnimationFrame(loop); } requestAnimationFrame(loop); </script> </body> </html>
thinking
The file looks good and complete. Let me verify it's present:
{"command":"ls -la /workspace/index.html"}-rw-r--r-- 1 node node 12792 Jul 31 03:01 /workspace/index.html
thinking
The file is created. Let me do a final summary of what's implemented.
`/workspace/index.html` is ready — a fully self-contained single file with everything inline. Here's what's implemented: **Heptagon** - 7-sided polygon with glowing blue edges, vertex dots, and subtle fill - Spins 360° every 5 seconds (continuous rotation) **20 Numbered Balls (1–20)** - Drop from near the center with slight random offsets - Each has a unique color from 5 curated palettes (reds, blues, greens, pinks, corals) - Radial gradient shading with specular highlight for 3D appearance - Motion trails that fade out **Physics** - **Gravity**: constant 400 px/s² downward - **Friction**: velocity damping (0.998/frame) for energy loss - **3 physics substeps** per frame for stability **Collision Detection (all hand-rolled)** - **Ball ↔ Wall**: signed distance to each heptagon edge; inward normal ensures containment; wall velocity from rotation is subtracted to get relative velocity before reflection; safety clamp prevents escaping - **Ball ↔ Ball**: distance check against sum-of-radii; overlap correction; impulse-based elastic collision with configurable restitution (0.9) - **Spin transfer**: tangential contact velocity at each collision imparts angular velocity to both objects, and spin decays via friction (0.97/frame) — the number on each ball visibly rotates to show this **Visual Polish** - Dark nebula gradient background with 80 twinkling stars - Blue glow on the heptagon outline - Balls sorted by Y depth for slight layering - No external dependencies, CDNs, or network requests
TAKE IT TO CODE
A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.
RUN
EVALUATION
Numbered balls bounce and settle inside a spinning heptagon with gravity; input has little effect since it's a passive simulation.
Vision grade · anthropic/claude-sonnet-5
MEASUREMENTS
Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.