I am having a blast of a time playing with a simple simulation I just built, with the assistance of GPT for a change: The double pendulum.

Here it is:



m1: 1.5
m2: 0.5
l1: 150
l2: 47
θ1: 180°
θ2: 180°
ω1: -0.1
ω2: 1.3
ENERGY:
Kinetic:   0.000
Potential: 0.000
Total:     0.000

GPT told me (correctly I believe) that the angular accelerations of the double pendulum can be computed as

\begin{align}
\alpha_1 &{}= \frac{-g(2m_1 + m_2)\sin\theta_1 - m_2 g \sin(\theta_1 - 2\theta_2) - 2\sin(\theta_1 - \theta_2)m_2(\omega_2^2 l_2 + \omega_1^2 l_1 \cos(\theta_1 - \theta_2))}{l_1 [2m_1 + m_2 - m_2\cos(2\theta_1 - 2\theta_2)]},\\
\alpha_2 &{}= \frac{2\sin(\theta_1 - \theta_2)\left(\omega_1^2 l_1(m_1 + m_2) + g(m_1 + m_2)\cos\theta_1 + \omega_2^2 l_2 m_2 \cos(\theta_1 - \theta_2)\right)}{l_2 [2m_1 + m_2 - m_2\cos(2\theta_1 - 2\theta_2)]}.
\end{align}

On the first try, GPT offered a simple integrator, however it was not stable. The system's energy was not conserved due to cumulative rounding errors, a problem common in similar simulations. Eventually, GPT replaced the integrator with a nice and clean 4th order Runge-Kutta integrator, and the result is a rock stable simulation. The final touches involved adding some user controls and a reasonably responsive layout.

I cannot really tell why, but watching it almost feels hypnotic.

For reference, here's the full source code of a self-contained page with the pendulum:

<!DOCTYPE html>
<html>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
  <title>Double Pendulum with Trails</title>
  <style>
body
{
  background-color: #CCC8D0;
  color: #000000;
  font-family: Arial, sans-serif;
}
button
{
  padding: 10px 20px;
  margin: 10px;
  font-size: 16px;
}

#controls
{
  color: black;
}

svg
{
  background-color: #ffffff;
  overflow: visible;
}

.input
{
 width: 3em;
 text-align: right;
 display: inline-block;
}

.inputd
{
 width: 3.5em;
}

.settings
{
  font-family: monospace;
}

.energy
{
  text-align: right;
  display: inline-block;
  width: 5em;
}

.recordLbl
{
  font-family: Arial, sans-serif;
  display: inline-block;
  margin-left: 10px;
  margin-bottom: 20px;
}

/* Flex layout */

#layout {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  gap: 30px;
  padding: 20px;
  flex-wrap: wrap;
}

#leftColumn {
  display: flex;
  flex-direction: column;
  gap: 30px;
}

#controls, #energyInfo {
  color: black;
  font-family: monospace;
}

#pendulumCanvas {
  display: flex;
}

@media (max-width: 768px) {
  #layout {
    flex-direction: column;
    align-items: center;
  }
 
  #leftColumn {
    flex-direction: column;
    align-items: center;
  }
}
  </style>
</head>
<body>
  <div id="layout">
    <div id="leftColumn">
<div id="controls">
  <button id="toggle">Start</button><br/>
  <label class='recordLbl'>
    <input type="checkbox" id="recordChk"/> Record to Video
  </label>
  <br/>
  <span class='settings'>
m1: <input type="range" id="m1" min="0.1" max="2"
step="0.1" value="1"/> <span id="m1val" class="input">1</span><br/>
m2: <input type="range" id="m2" min="0.1" max="2"
step="0.1" value="1.5"/> <span id="m2val" class="input">1.5</span><br/>
l1: <input type="range" id="l1" min="10" max="150"
step="1" value="100"/><span id="l1val" class="input">100</span><br/>
l2: <input type="range" id="l2" min="10" max="150"
step="1" value="100"/><span id="l2val" class="input">100</span><br/>
θ1: <input type="range" id="theta1" min="0" max="360"
step="1" value="0"/><span id="theta1val" class="input inputd">0°</span><br/>
θ2: <input type="range" id="theta2" min="0" max="360" step="1"
value="0"/><span id="theta2val" class="input inputd">0°</span><br/>
ω1: <input type="range" id="omega1" min="-3" max="3"
step="0.1" value="1.1"/><span id="omega1val" class="input">1.1</span><br/>
ω2: <input type="range" id="omega2" min="-3" max="3"
step="0.1" value="-1.1"/><span id="omega2val" class="input">-1.1</span><br/>
  </span>
</div>

<div id="energyInfo">
  <u>ENERGY</u>:<br>
Kinetic:&nbsp;&nbsp; <span class="energy" id="kinE">0.000</span><br>
  Potential: <span class="energy" id="potE">0.000</span><br>
Total:&nbsp;&nbsp;&nbsp;&nbsp; <span class="energy" id="totE">0.000</span>
</div>

</div>

<svg id="pendulumCanvas" width="600" height="400">
  <g id="pendulum" transform="translate(300,200)">
    <polyline id="trail14" stroke="#d5dbff" stroke-width="1" fill="none"/>
    <polyline id="trail13" stroke="#a9b6ff" stroke-width="1" fill="none"/>
    <polyline id="trail12" stroke="#7e92ff" stroke-width="1" fill="none"/>
    <polyline id="trail11" stroke="#526dff" stroke-width="1" fill="none"/>
    <polyline id="trail24" stroke="#ffd3db" stroke-width="1" fill="none"/>
    <polyline id="trail23" stroke="#ffa6b6" stroke-width="1" fill="none"/>
    <polyline id="trail22" stroke="#ff7990" stroke-width="1" fill="none"/>
    <polyline id="trail21" stroke="#ff4c6b" stroke-width="1" fill="none"/>
       
    <line id="rod1" stroke="#000000" stroke-width="3"/>
    <line id="rod2" stroke="#000000" stroke-width="3"/>
        
    <circle id="mass1" r="8" fill="#61dafb"/>
    <circle id="mass2" r="8" fill="#e06c75"/>
  </g>
</svg>

</div>

<canvas id="recordCanvas" width="600" height="400" style="display:none;"></canvas>


<script>

// Animation & physics parameters (customizable)

const paramElems = ['m1','m2','l1','l2','theta1','theta2',
'omega1','omega2'].map(id=>document.getElementById(id));
const paramDisplay = paramElems.map(el=>document.getElementById(el.id+'val'));

function readParams()
{
  const [m1,m2,l1,l2,theta1,theta2,omega1,omega2] = paramElems.map(e=>+e.value);
  return {m1,m2,l1,l2,theta1:theta1*Math.PI/180,theta2:theta2*Math.PI/180,
          omega1:omega1,omega2:omega2,g:9.81,dt:0.0167,sf:50,df:5,
          trail1Length:100,trail2Length:400};
}

let params=readParams();

let running = false;
const toggleButton = document.getElementById('toggle');

let worker;

const trail1Points = [[],[],[],[],[]];
const trail2Points = [[],[],[],[],[]];

const trail11 = document.getElementById('trail11');
const trail12 = document.getElementById('trail12');
const trail13 = document.getElementById('trail13');
const trail14 = document.getElementById('trail14');
const trail21 = document.getElementById('trail21');
const trail22 = document.getElementById('trail22');
const trail23 = document.getElementById('trail23');
const trail24 = document.getElementById('trail24');

const mass1 = document.getElementById('mass1');
const mass2 = document.getElementById('mass2');
const rod1 = document.getElementById('rod1');
const rod2 = document.getElementById('rod2');

// Create web worker for smooth physics computations
const workerBlob = new Blob(
[`
  let params, running = false;
  onmessage = function(e)
  {
    if(e.data.type === 'init') params = e.data.params;
    if(e.data.type === 'start') { running=true; run(); }
    if(e.data.type === 'stop') running=false;
   if(e.data.type === 'energy')
   {
     const {params} = e.data;
     postMessage({type: 'energyResult',
                  energy: energies(params.theta1, params.theta2,
                          params.omega1, params.omega2, params)});
   }
};

  function accelerations(theta1, theta2, omega1, omega2, params)
  {
    const {m1,m2,l1,l2,g} = params;
    const delta = theta1 - theta2;
    const den1 = (2*m1 + m2 - m2*Math.cos(2*delta));

    const a1 = (-g*(2*m1+m2)*Math.sin(theta1)
                - m2*g*Math.sin(theta1 - 2*theta2)
                - 2*Math.sin(delta)*m2*(omega2*omega2*l2
                               + omega1*omega1*l1*Math.cos(delta)))
               /(l1 * den1);

    const a2 = (2*Math.sin(delta)*(omega1*omega1*l1*(m1+m2)
                + g*(m1+m2)*Math.cos(theta1)
                + omega2*omega2*l2*m2*Math.cos(delta)))
               /(l2 * den1);

    return [a1, a2];
  }

  function energies(theta1, theta2, omega1, omega2, params)
  {
    const {m1, m2, l1, l2, g} = params;

    // positions (y positive downward):
    const y1 = l1 * Math.cos(theta1);
    const y2 = y1 + l2 * Math.cos(theta2);

    // velocities squared:
    const v1sq = (l1 * omega1)**2;
    const v2sq = v1sq + (l2 * omega2)**2 + 2 * l1 * l2 * omega1 * omega2 * Math.cos(theta1 - theta2);

    // kinetic energy:
    const T = 0.5 * m1 * v1sq + 0.5 * m2 * v2sq;

    // potential energy (assuming y-positive downward, so negative of classical mechanics standard):
    const V = -g * (m1 * y1 + m2 * y2);

    return {kinetic: T, potential: V, total: T + V};
  }

  function run()
  {
    let {theta1, theta2, omega1, omega2, dt, sf, df} = params;

    function derivatives(state, params)
    {
      const [theta1, theta2, omega1, omega2] = state;
      const [alpha1, alpha2] = accelerations(theta1, theta2, omega1, omega2, params);
      return [omega1, omega2, alpha1, alpha2];
    }

    function rk4Step(state, h, params)
    {
      const add = (a,b,f=1) => a.map((x,i)=>x+f*b[i]);

      const k1 = derivatives(state, params);
      const k2 = derivatives(add(state,k1,h/2), params);
      const k3 = derivatives(add(state,k2,h/2), params);
      const k4 = derivatives(add(state,k3,h), params);

      return state.map((x,i)=> x + (h/6)*(k1[i]+2*k2[i]+2*k3[i]+k4[i]));
    }

    function step()
    {
      const h = dt / df;
      let state = [theta1, theta2, omega1, omega2];

      for (let i = 0; i < sf; i++)
      {
        state = rk4Step(state, h, params);
      }

      [theta1, theta2, omega1, omega2] = state;

      postMessage({theta1, theta2,
        energy: energies(theta1, theta2, omega1, omega2, params)});
      if(running)setTimeout(step, dt * 1000);
    }
    step();
  }
`]);

worker = new Worker(URL.createObjectURL(workerBlob));

function disableControls()
{
   paramElems.forEach(el => el.disabled = true);
   recordChk.disabled = true;
}

function enableControls()
{
   paramElems.forEach(el => el.disabled = false);
   recordChk.disabled = false;
}

toggleButton.onclick = function()
{
  running = !running;
  toggleButton.textContent = running ? 'Stop':'Start';
  if (running)
  {
    worker.postMessage({type:'init', params});
    trail1Points[0].length = 0;
    trail1Points[1].length = 0;
    trail1Points[2].length = 0;
    trail1Points[3].length = 0;
    trail2Points[0].length = 0;
    trail2Points[1].length = 0;
    trail2Points[2].length = 0;
  trail2Points[3].length = 0;
    worker.postMessage({type:'start'});
   disableControls();
 }
  else
  {
    worker.postMessage({type:'stop'});
   enableControls();
   // Request the energy to be updated on stop
   worker.postMessage({type: 'energy', params: params});
 }
};

const kinE = document.getElementById("kinE");
const potE = document.getElementById("potE");
const totE = document.getElementById("totE");

function paint(x1, x2, y1, y2)
{
  const {m1,m2} = params;
  rod1.setAttribute('x1',0); rod1.setAttribute('y1',0);
  rod1.setAttribute('x2',x1); rod1.setAttribute('y2',y1);
  rod2.setAttribute('x1',x1); rod2.setAttribute('y1',y1);
  rod2.setAttribute('x2',x2); rod2.setAttribute('y2',y2);
  mass1.setAttribute('cx',x1); mass1.setAttribute('cy',y1);
  mass1.setAttribute('cx',x1); mass1.setAttribute('cy',y1);
  mass2.setAttribute('cx',x2); mass2.setAttribute('cy',y2);
  mass1.setAttribute('r',5+3*m1);
  mass2.setAttribute('r',5+3*m2);

}

function tpaint(theta1, theta2)
{
  const {l1,l2} = params;
  const x1 = l1*Math.sin(theta1),y1 = l1*Math.cos(theta1);
  const x2 = x1+l2*Math.sin(theta2), y2 = y1+l2*Math.cos(theta2);

  paint(x1, x2, y1, y2);
}

worker.onmessage = function(event)
{
 if (event.data.type === 'energyResult')
 {
   const { energy } = event.data;
   kinE.textContent = energy.kinetic.toFixed(3);
   potE.textContent = energy.potential.toFixed(3);
   totE.textContent = energy.total.toFixed(3);
   return;
 }

  const {l1,l2,trail1Length,trail2Length} = params;
  const {theta1,theta2,energy} = event.data;
  const x1 = l1*Math.sin(theta1),y1 = l1*Math.cos(theta1);
  const x2 = x1+l2*Math.sin(theta2), y2 = y1+l2*Math.cos(theta2);

  paint(x1, x2, y1, y2);

  // Update trails
  trail1Points[0].push(`${x1},${y1}`);
  trail2Points[0].push(`${x2},${y2}`);

  for (let t = 0; t < 4; t++)
  {
    if (trail1Points[t].length > trail1Length / 4)
    {
      if (t < 3)
      {
        if (trail1Points[t+1].length == 0) trail1Points[t+1].push(trail1Points[t][0]);
        trail1Points[t+1].push(trail1Points[t][1]);
      }
      trail1Points[t].shift();
    }
    if (trail2Points[t].length > trail2Length / 4)
    {
      if (t < 3)
      {
        if (trail2Points[t+1].length == 0) trail2Points[t+1].push(trail2Points[t][0]);
        trail2Points[t+1].push(trail2Points[t][1]);
      }
      trail2Points[t].shift();
    }
}

  trail11.setAttribute('points', trail1Points[0].join(' '));
  trail12.setAttribute('points', trail1Points[1].join(' '));
  trail13.setAttribute('points', trail1Points[2].join(' '));
  trail14.setAttribute('points', trail1Points[3].join(' '));
  trail21.setAttribute('points', trail2Points[0].join(' '));
  trail22.setAttribute('points', trail2Points[1].join(' '));
  trail23.setAttribute('points', trail2Points[2].join(' '));
trail24.setAttribute('points', trail2Points[3].join(' '));

  // Update displayed energies clearly formatted:
  kinE.textContent = energy.kinetic.toFixed(3);
  potE.textContent = energy.potential.toFixed(3);
  totE.textContent = energy.total.toFixed(3);

  // if we're recording, update canvas for current frame
  if (recorder && recorder.state === 'recording') svgToCanvas();
};

const recordChk = document.getElementById('recordChk');
const canvas = document.getElementById('recordCanvas');
const ctx = canvas.getContext('2d');
let recorder, chunks = [];

// Function to copy SVG content onto Canvas
function svgToCanvas()
{
  const svgEl = document.getElementById('pendulumCanvas');
  const svgData = new XMLSerializer().serializeToString(svgEl);
  const dataUrl = 'data:image/svg+xml;charset=utf-8,'
                   + encodeURIComponent(svgData);

  const img = new Image();
  img.onload = function()
  {
      ctx.fillStyle = getComputedStyle(
            document.getElementById('pendulumCanvas'))
                        ['background-color'];  // match the page background
      ctx.fillRect(0,0,canvas.width,canvas.height);
      ctx.drawImage(img, 0, 0);
  };
  img.src = dataUrl;
}

// Modify the toggle button logic to handle recording
const originalToggleHandler = toggleButton.onclick;  // save existing logic
toggleButton.onclick = function()
{
  const shouldStartRecording = recordChk.checked && !running;  // first start click

  if (shouldStartRecording)
  {
    chunks = [];
    const stream = canvas.captureStream(60); // 60 FPS
    recorder = new MediaRecorder(stream, { mimeType: "video/webm; codecs=vp9" });
    recorder.ondataavailable = e => chunks.push(e.data);
    recorder.onstop = saveVideo;
    recorder.start();
    console.log('Recording started');
  } 
  else if (running && recorder && recorder.state === 'recording')
  {
    recorder.stop(); 
    console.log('Recording stopped');
  }

  originalToggleHandler(); // execute existing logic
}


function saveVideo()
{
  const blob = new Blob(chunks, { type: 'video/webm' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  document.body.appendChild(a);
  a.style.display = 'none';
  a.href = url;
  a.download = 'double-pendulum.webm';  // Browser natively saves in .webm format
  a.click();
  window.URL.revokeObjectURL(url);
  a.remove();
}

paramElems.forEach((el,i)=>el.oninput=() =>
{
  paramDisplay[i].textContent=el.value+(el.id.includes('theta')?'°':'');
  params = readParams();
  tpaint(params.theta1,params.theta2);
worker.postMessage({type: 'energy', params: params});
});

params = readParams();
tpaint(params.theta1, params.theta2);
worker.postMessage({type: 'energy', params: params});

</script>
</body>
</html>