oneshotlm
Compare all models on this prompt →

Double pendulum · qwen3.6-flash

Simulate a chaotic double pendulum on a canvas using the correct equations of motion, drawing the arms and a colorful fading trail of the tip.

⛨ sandboxed Open in new tab ↗
transcript · 18 messages
user

Simulate a chaotic double pendulum on a canvas using the correct equations of motion, drawing the arms and a colorful fading trail of the tip.

assistant
thinking
The user wants a single-page HTML file that simulates a double pendulum with:
1. Correct physics equations of motion (Lagrangian mechanics for a double pendulum)
2. Canvas rendering showing both arms and the pivot
3. A colorful fading trail of the tip position over time
4. All CSS/JS inline, no external resources

Let me build this carefully. The equations of motion for a double pendulum are well-known from Lagrangian mechanics.

The state variables are θ₁, θ₂ (angles) and ω₁, ω₂ (angular velocities).

The equations:
θ₁'' = (-g(2m₁+m₂)sin(θ₁) - m₂g sin(θ₁-2θ₂) - 2sin(θ₁-θ₂)m₂(θ₂'²L₂ + θ₁'²L₁cos(θ₁-θ₂))) / (L₁(2m₁+m₂-m₂cos(2θ₁-2θ₂)))

θ₂'' = (2sin(θ₁-θ₂)(θ₁'²L₁(m₁+m₂) + g(m₁+m₂)cos(θ₁) + θ₂'²L₂m₂cos(θ₁-θ₂))) / (L₂(2m₁+m₂-m₂cos(2θ₁-2θ₂)))

I'll use RK4 integration for accuracy. Let me make it visually appealing with nice colors.
write /tmp/ws/index.html (9019 bytes)
toolResult

Successfully wrote 9019 bytes to /tmp/ws/index.html

assistant
thinking
There's a bug - I defined helper functions `px2()` and `py2()` at the bottom but they reference stale closures. Let me fix this properly by computing positions inline in the drawFrame function.

Let me fix a closure issue in the code:

edit {"path":"/tmp/ws/index.html","edits":[{"oldText":" // Arm 2\n ctx.beginPath();\n ctx.moveTo(px1, py1);\n ctx.lineTo(px2(), py2());\n ct
toolResult

Successfully replaced 1 block(s) in /tmp/ws/index.html.

assistant
thinking
I moved the tipX/tipY computation earlier. Let me check the current code to make sure tipX and tipY are computed before Arm 2 uses them.

Let me check the resulting file:

read {"path":"/tmp/ws/index.html","offset":130,"limit":70}
toolResult

// --- Positions --- function positions(s) { const [t1, t2] = s; const x1 = L1 * Math.sin(t1); const y1 = L1 * Math.cos(t1); const x2 = x1 + L2 * Math.sin(t2); const y2 = y1 + L2 * Math.cos(t2); return { x1, y1, x2, y2 }; } // --- Energy --- function energy(s) { const [t1, t2, w1, w2] = s; const p = positions(s); const ke = 0.5 * M1 * (L1*L1*w1*w1) + 0.5 * M2 * ((L1*w1*Math.sin(t1) + L2*w2*Math.sin(t2))**2 + (L1*w1*Math.cos(t1) + L2*w2*Math.cos(t2))**2); const pe = -(M1 + M2) * G * L1 * Math.cos(t1) - M2 * G * L2 * Math.cos(t2); return { ke, pe, total: ke + pe }; } // --- Trail colour cycling (hue rotates with position) --- function hueFrom(x, y) { return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; } // --- Init trail --- function reset() { state = [Math.PI * 0.75, Math.PI * 0.5, 0, 0]; trail = []; frameCount = 0; } reset(); // --- Slider wiring --- const sliders = ['m1','m2','l1','l2']; sliders.forEach(k => { const sl = document.getElementById('sl_'+k); const sp = document.getElementById('v_'+k); sl.addEventListener('input', () => { if (k==='m1') M1 = parseFloat(sl.value); else if (k==='m2') M2 = parseFloat(sl.value); else if (k==='l1') L1 = parseFloat(sl.value); else if (k==='l2') L2 = parseFloat(sl.value); sp.textContent = parseFloat(sl.value).toFixed(1); // Reset trail when params change so colours stay sensible trail = []; }); }); document.getElementById('btn_reset').addEventListener('click', reset); const btnFreeze = document.getElementById('btn_freeze'); btnFreeze.addEventListener('click', () => { paused = !paused; btnFreeze.textContent = paused ? 'Play' : 'Pause'; }); // --- Background fill (dark with slight fade) --- ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, SIZE, SIZE); // --- Animation loop --- function draw() { requestAnimationFrame(draw); if (paused) { drawFrame(); return; } // Sub-step integration (more steps = more stability) const subSteps = 12; [111 more lines in file. Use offset=200 to continue.]

assistant
thinking
Let me read the rest of the file to check for issues.
read {"path":"/tmp/ws/index.html","offset":200}
toolResult

const dt = 0.016 / subSteps; for (let i = 0; i < subSteps; i++) { state = rk4(state, dt); } const pos = positions(state); const px2 = CX + pos.x2 * PX_SCALE; const py2 = CY - pos.y2 * PX_SCALE; // canvas y is flipped trail.push({ x: px2, y: py2, h: hueFrom(pos.x2, pos.y2), a: 1.0 }); if (trail.length > MAX_TRAIL) trail.shift(); frameCount++; drawFrame(); } function drawFrame() { // Darken slightly each frame for trail fade effect // But since we store alpha in trail we redraw clean bg ctx.fillStyle = 'rgba(10, 10, 15, 0.15)'; ctx.fillRect(0, 0, SIZE, SIZE); // Clear fully every few frames to prevent buildup artifacts if (frameCount % 6 === 0) { ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, SIZE, SIZE); } const pos = positions(state); // Draw trail if (trail.length > 1) { for (let i = 1; i < trail.length; i++) { const prev = trail[i - 1]; const cur = trail[i]; const t = i / trail.length; // 0=old, 1=new const alpha = t * t * 0.85; // quadratic fade ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(cur.x, cur.y); ctx.strokeStyle = `hsla(${cur.h}, 85%, 60%, ${alpha})`; ctx.lineWidth = 1.2 + t * 1.5; ctx.stroke(); } } // Arm 1 const px1 = CX + pos.x1 * PX_SCALE; const py1 = CY - pos.y1 * PX_SCALE; ctx.beginPath(); ctx.moveTo(CX, CY); ctx.lineTo(px1, py1); ctx.strokeStyle = '#ddd'; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.stroke(); // Arm 2 ctx.beginPath(); ctx.moveTo(px1, py1); ctx.lineTo(tipX, tipY); ctx.strokeStyle = '#ccc'; ctx.lineWidth = 2.5; ctx.stroke(); // Pivot joint ctx.beginPath(); ctx.arc(CX, CY, 5, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); // Mass 1 ctx.beginPath(); ctx.arc(px1, py1, 6 + M1 * 0.8, 0, Math.PI * 2); ctx.fillStyle = '#aab'; ctx.fill(); // Tip glow const tipHue = hueFrom(pos.x2, pos.y2); const grd = ctx.createRadialGradient(tipX, tipY, 0, tipX, tipY, 25); grd.addColorStop(0, `hsla(${tipHue}, 90%, 70%, 0.5)`); grd.addColorStop(1, 'transparent'); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(tipX, tipY, 25, 0, Math.PI * 2); ctx.fill(); // Tip dot ctx.beginPath(); ctx.arc(tipX, tipY, 5 + M2 * 0.6, 0, Math.PI * 2); ctx.fillStyle = `hsl(${tipHue}, 80%, 65%)`; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.2; ctx.stroke(); // Energy readout const e = energy(state); document.getElementById('energy').textContent = `E: ${e.total.toFixed(1)} J | KE: ${e.ke.toFixed(1)} J | PE: ${e.pe.toFixed(1)} J`; } // helper closures used above — rebind function px2() { return CX + positions(state).x2 * PX_SCALE; } function py2() { return CY - positions(state).y2 * PX_SCALE; } requestAnimationFrame(draw); </script> </body> </html>

assistant
thinking
The code has an issue - `tipX` and `tipY` are referenced in `drawFrame()` before they're defined. They're computed inside the `draw()` function but used in `drawFrame()`. I need to fix this by computing them at the top of `drawFrame()`.

I see a bug — `tipX` and `tipY` are used in `drawFrame()` but only defined in `draw()`. Let me fix that:

edit {"path":"/tmp/ws/index.html","edits":[{"oldText":" const pos = positions(state);\n\n // Draw trail","newText":" const pos = positions(sta
toolResult

Successfully replaced 1 block(s) in /tmp/ws/index.html.

assistant
thinking
Now let me clean up the dead helper closures at the bottom since we no longer need them.
edit {"path":"/tmp/ws/index.html","edits":[{"oldText":"// helper closures used above — rebind\nfunction px2() { return CX + positions(state).x2 *
toolResult

Successfully replaced 1 block(s) in /tmp/ws/index.html.

assistant
thinking
Let me verify the final file looks correct and has no remaining issues.
read {"path":"/tmp/ws/index.html","offset":260}
toolResult

ctx.beginPath(); ctx.moveTo(px1, py1); ctx.lineTo(tipX, tipY); ctx.strokeStyle = '#ccc'; ctx.lineWidth = 2.5; ctx.stroke(); // Pivot joint ctx.beginPath(); ctx.arc(CX, CY, 5, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); // Mass 1 ctx.beginPath(); ctx.arc(px1, py1, 6 + M1 * 0.8, 0, Math.PI * 2); ctx.fillStyle = '#aab'; ctx.fill(); // Tip glow const tipHue = hueFrom(pos.x2, pos.y2); const grd = ctx.createRadialGradient(tipX, tipY, 0, tipX, tipY, 25); grd.addColorStop(0, `hsla(${tipHue}, 90%, 70%, 0.5)`); grd.addColorStop(1, 'transparent'); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(tipX, tipY, 25, 0, Math.PI * 2); ctx.fill(); // Tip dot ctx.beginPath(); ctx.arc(tipX, tipY, 5 + M2 * 0.6, 0, Math.PI * 2); ctx.fillStyle = `hsl(${tipHue}, 80%, 65%)`; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.2; ctx.stroke(); // Energy readout const e = energy(state); document.getElementById('energy').textContent = `E: ${e.total.toFixed(1)} J | KE: ${e.ke.toFixed(1)} J | PE: ${e.pe.toFixed(1)} J`; } requestAnimationFrame(draw); </script> </body> </html>

assistant
thinking
The file looks good now. Let me verify the complete file is syntactically correct and there are no issues.

Let me verify the whole file is clean:

read {"path":"/tmp/ws/index.html"}
toolResult

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Chaotic Double Pendulum</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0a0a0f; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: 'Courier New', monospace; color: #aaa; overflow: hidden; } canvas { border-radius: 8px; box-shadow: 0 0 60px rgba(100, 60, 255, 0.15); } #controls { margin-top: 16px; display: flex; gap: 18px; flex-wrap: wrap; justify-content: center; font-size: 13px; } .ctrl-group { display: flex; flex-direction: column; align-items: center; gap: 4px; } label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #666; } input[type=range] { width: 110px; accent-color: #7b5cff; } button { background: #222; color: #bbb; border: 1px solid #444; padding: 6px 18px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 13px; transition: all 0.2s; } button:hover { background: #333; color: #fff; } #energy { margin-top: 10px; font-size: 11px; color: #555; letter-spacing: 0.5px; } </style> </head> <body> <canvas id="c"></canvas> <div id="controls"> <div class="ctrl-group"><label>m₁ (kg)</label><input type="range" id="sl_m1" min="1" max="10" step="0.5" value="4"><span id="v_m1">4.0</span></div> <div class="ctrl-group"><label>m₂ (kg)</label><input type="range" id="sl_m2" min="1" max="10" step="0.5" value="3"><span id="v_m2">3.0</span></div> <div class="ctrl-group"><label>L₁ (m)</label><input type="range" id="sl_l1" min="0.5" max="3" step="0.1" value="1.5"><span id="v_l1">1.5</span></div> <div class="ctrl-group"><label>L₂ (m)</label><input type="range" id="sl_l2" min="0.5" max="3" step="0.1" value="1.5"><span id="v_l2">1.5</span></div> <button id="btn_reset">Reset</button> <button id="btn_freeze">Pause</button> </div> <div id="energy">E: -- J &nbsp;|&nbsp; KE: -- J &nbsp;|&nbsp; PE: -- J</div> <script> const canvas = document.getElementById('c'); const ctx = canvas.getContext('2d'); // --- Canvas sizing --- const SIZE = Math.min(window.innerWidth - 32, window.innerHeight - 160, 700); canvas.width = SIZE; canvas.height = SIZE; const CX = SIZE / 2; // origin at center top-ish const CY = SIZE * 0.25; // pivot Y const PX_SCALE = SIZE / 9; // pixels per metre of length const MAX_TRAIL = 6000; // --- Physics parameters --- let M1 = 4, M2 = 3, L1 = 1.5, L2 = 1.5; const G = 9.81; // State: [theta1, theta2, omega1, omega2] let state = [Math.PI * 0.75, Math.PI * 0.5, 0, 0]; let paused = false; let trail = []; // array of {x, y, h} let frameCount = 0; // --- Derivatives from Lagrangian EOM --- function deriv(s) { const [t1, t2, w1, w2] = s; const dt = t1 - t2; const sinDt = Math.sin(dt); const cosDt = Math.cos(dt); const den = 2 * M1 + M2 - M2 * Math.cos(2 * t1 - 2 * t2); // = 2*M1 + M2*(1-cos(2dt)) const a1 = (-G * (2 * M1 + M2) * Math.sin(t1) - M2 * G * Math.sin(t1 - 2 * t2) - 2 * sinDt * M2 * (w2 * w2 * L2 + w1 * w1 * L1 * cosDt)) / (L1 * den); const a2 = (2 * sinDt * (w1 * w1 * L1 * (M1 + M2) + G * (M1 + M2) * Math.cos(t1) + w2 * w2 * L2 * M2 * cosDt)) / (L2 * den); return [w1, w2, a1, a2]; } // --- RK4 step --- function rk4(s, dt) { const k1 = deriv(s); const s2 = s.map((v, i) => v + k1[i] * dt * 0.5); const k2 = deriv(s2); const s3 = s.map((v, i) => v + k2[i] * dt * 0.5); const k3 = deriv(s3); const s4 = s.map((v, i) => v + k3[i] * dt); const k4 = deriv(s4); return s.map((v, i) => v + (k1[i] + 2*k2[i] + 2*k3[i] + k4[i]) * dt / 6); } // --- Positions --- function positions(s) { const [t1, t2] = s; const x1 = L1 * Math.sin(t1); const y1 = L1 * Math.cos(t1); const x2 = x1 + L2 * Math.sin(t2); const y2 = y1 + L2 * Math.cos(t2); return { x1, y1, x2, y2 }; } // --- Energy --- function energy(s) { const [t1, t2, w1, w2] = s; const p = positions(s); const ke = 0.5 * M1 * (L1*L1*w1*w1) + 0.5 * M2 * ((L1*w1*Math.sin(t1) + L2*w2*Math.sin(t2))**2 + (L1*w1*Math.cos(t1) + L2*w2*Math.cos(t2))**2); const pe = -(M1 + M2) * G * L1 * Math.cos(t1) - M2 * G * L2 * Math.cos(t2); return { ke, pe, total: ke + pe }; } // --- Trail colour cycling (hue rotates with position) --- function hueFrom(x, y) { return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360; } // --- Init trail --- function reset() { state = [Math.PI * 0.75, Math.PI * 0.5, 0, 0]; trail = []; frameCount = 0; } reset(); // --- Slider wiring --- const sliders = ['m1','m2','l1','l2']; sliders.forEach(k => { const sl = document.getElementById('sl_'+k); const sp = document.getElementById('v_'+k); sl.addEventListener('input', () => { if (k==='m1') M1 = parseFloat(sl.value); else if (k==='m2') M2 = parseFloat(sl.value); else if (k==='l1') L1 = parseFloat(sl.value); else if (k==='l2') L2 = parseFloat(sl.value); sp.textContent = parseFloat(sl.value).toFixed(1); // Reset trail when params change so colours stay sensible trail = []; }); }); document.getElementById('btn_reset').addEventListener('click', reset); const btnFreeze = document.getElementById('btn_freeze'); btnFreeze.addEventListener('click', () => { paused = !paused; btnFreeze.textContent = paused ? 'Play' : 'Pause'; }); // --- Background fill (dark with slight fade) --- ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, SIZE, SIZE); // --- Animation loop --- function draw() { requestAnimationFrame(draw); if (paused) { drawFrame(); return; } // Sub-step integration (more steps = more stability) const subSteps = 12; const dt = 0.016 / subSteps; for (let i = 0; i < subSteps; i++) { state = rk4(state, dt); } const pos = positions(state); const px2 = CX + pos.x2 * PX_SCALE; const py2 = CY - pos.y2 * PX_SCALE; // canvas y is flipped trail.push({ x: px2, y: py2, h: hueFrom(pos.x2, pos.y2), a: 1.0 }); if (trail.length > MAX_TRAIL) trail.shift(); frameCount++; drawFrame(); } function drawFrame() { // Darken slightly each frame for trail fade effect // But since we store alpha in trail we redraw clean bg ctx.fillStyle = 'rgba(10, 10, 15, 0.15)'; ctx.fillRect(0, 0, SIZE, SIZE); // Clear fully every few frames to prevent buildup artifacts if (frameCount % 6 === 0) { ctx.fillStyle = '#0a0a0f'; ctx.fillRect(0, 0, SIZE, SIZE); } const pos = positions(state); const tipX = CX + pos.x2 * PX_SCALE; const tipY = CY - pos.y2 * PX_SCALE; // Draw trail if (trail.length > 1) { for (let i = 1; i < trail.length; i++) { const prev = trail[i - 1]; const cur = trail[i]; const t = i / trail.length; // 0=old, 1=new const alpha = t * t * 0.85; // quadratic fade ctx.beginPath(); ctx.moveTo(prev.x, prev.y); ctx.lineTo(cur.x, cur.y); ctx.strokeStyle = `hsla(${cur.h}, 85%, 60%, ${alpha})`; ctx.lineWidth = 1.2 + t * 1.5; ctx.stroke(); } } // Arm 1 const px1 = CX + pos.x1 * PX_SCALE; const py1 = CY - pos.y1 * PX_SCALE; ctx.beginPath(); ctx.moveTo(CX, CY); ctx.lineTo(px1, py1); ctx.strokeStyle = '#ddd'; ctx.lineWidth = 3; ctx.lineCap = 'round'; ctx.stroke(); // Arm 2 ctx.beginPath(); ctx.moveTo(px1, py1); ctx.lineTo(tipX, tipY); ctx.strokeStyle = '#ccc'; ctx.lineWidth = 2.5; ctx.stroke(); // Pivot joint ctx.beginPath(); ctx.arc(CX, CY, 5, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); // Mass 1 ctx.beginPath(); ctx.arc(px1, py1, 6 + M1 * 0.8, 0, Math.PI * 2); ctx.fillStyle = '#aab'; ctx.fill(); // Tip glow const tipHue = hueFrom(pos.x2, pos.y2); const grd = ctx.createRadialGradient(tipX, tipY, 0, tipX, tipY, 25); grd.addColorStop(0, `hsla(${tipHue}, 90%, 70%, 0.5)`); grd.addColorStop(1, 'transparent'); ctx.fillStyle = grd; ctx.beginPath(); ctx.arc(tipX, tipY, 25, 0, Math.PI * 2); ctx.fill(); // Tip dot ctx.beginPath(); ctx.arc(tipX, tipY, 5 + M2 * 0.6, 0, Math.PI * 2); ctx.fillStyle = `hsl(${tipHue}, 80%, 65%)`; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.2; ctx.stroke(); // Energy readout const e = energy(state); document.getElementById('energy').textContent = `E: ${e.total.toFixed(1)} J | KE: ${e.ke.toFixed(1)} J | PE: ${e.pe.toFixed(1)} J`; } requestAnimationFrame(draw); </script> </body> </html>

assistant
thinking
The file looks good now. Let me verify there are no issues:

1. The `deriv()` function uses the correct Lagrangian equations of motion for a double pendulum
2. RK4 integration is properly implemented with 4th-order accuracy
3. Trail stores position data and draws with HSL color cycling based on angular position
4. All variables (`tipX`, `tipY`) are properly defined before use in `drawFrame()`
5. Energy computation includes both kinetic and potential energy terms
6. UI controls allow adjusting masses, lengths, reset, and pause
7. No external dependencies - fully self-contained

This looks complete and correct. The file ends with `</html>` which is proper.

Done! The file `/tmp/ws/index.html` contains a complete, self-contained chaotic double pendulum simulation featuring: **Physics:** - Correct Lagrangian equations of motion for the double pendulum (the exact nonlinear coupled ODEs) - RK4 (4th-order Runge-Kutta) integration with 12 sub-steps per frame for stability - Real-time energy tracking (KE, PE, total) displayed at the bottom **Visuals:** - Two rigid arms drawn from pivot → joint → tip - Mass-scaled bob circles at each joint - A colorful fading trail of the tip's trajectory — hue cycles through the spectrum based on the tip's angular position - Glowing tip dot with radial gradient halo - Dark background with subtle fade effect **Controls:** - Sliders for m₁, m₂, L₁, L₂ to change mass/length parameters live - Reset button to restart from initial conditions - Pause/Play toggle

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

modelqwen3.6-flash
vendorQwen
harnesspi-agent
generated2026-08-01

EVALUATION

4/5 working

Double pendulum simulation runs continuously with colorful fading trails; sliders/reset/pause present but pendulum unaffected by inputs.

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.

idle motion5.3%
on click3.3%
on drag3.9%
on wheel2.0%
on enter + space3.0%
on arrow keys4.6%
on w/a/s/d6.2%
frame spread19.1 / 255
console errors0
js errors none

METRICS

runtime37.7s
tokens in64k
tokens out5.6k
cached0
cost / run$0.0182