GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Double Pendulum Simulation: 9 Chaos Scenes

Change the start angle by one millionth of a radian and the tip draws a completely different path — scene 03 stacks two pendulums so you watch the fork happen.

See All Nine Double Pendulum Scenes Run Right on This Page

Every canvas in the grid is already moving on load, and all nine start from the same object: one arm hanging from a pivot with a second arm hanging from its end, which I call the tip. Nothing needs a click except scene 07, where your pointer or arrow keys choose the start angles.

Auto-plays · click a tile to jump to its section · all nine in one zip

Double Pendulum Simulation: 9 Scenes Where One Integration Step Shapes the Chaos

If you want the double pendulum equations of motion in a form you can change and watch, this page keeps them to two functions. accTheta1 and accTheta2 turn the two arm angles and speeds into accelerations, and rk4Step pushes the state forward one small step with four Runge-Kutta stages. Both arm lengths L1 and L2 and both masses M1 and M2 are 1.0, gravity G is 9.81, and an angle of 0 means straight down. There is no random number and no clock call in any scene, so the same start angles always draw the same path.

The nine scenes keep that calculation and change only what is drawn from it. Scene 01 shows the bare skeleton, 02 keeps the path of the tip, and 04 draws the two angles against each other on one plane. Scenes 03 and 06 stack pendulums whose start angles differ by a hair, 05 compares two ways of stepping, 07 lets you set the start by hand, 08 colors every possible start as a map, and 09 ties it all into a brass desk toy.

All of them run faster than real time. Each frame advances SUBSTEPS steps of DT 0.01 seconds, which is 0.03 seconds per frame (0.04 in scene 03), and the fixed 16 ms accumulator makes that about twice real speed (about 2.5 times in scene 03). No library, no WebGL and no outside address is used. For a gentler physics warm-up, try 9 Bouncing Ball Animation Patterns With Physics or Spinning Top Animation: 9 Canvas Physics Scenes first.

Code for Each Pendulum Calculation Technique

Every snippet below is a run of consecutive lines cut from the demo source in their original order. The shared functions (accTheta1, accTheta2, rk4Step) are identical in all nine, so scene 01 uses them once and the other scenes show only the lines that make them different. You get the whole files by opening the zip.

01Pendulum Stepped by Solving Both Arm Accelerations

stepFrame() calls rk4Step(sim, DT) SUBSTEPS times per frame, then asks accTheta1 and accTheta2 again for the two accelerations accA and accB. The two bars at the top read them on a ±90 rad/s² scale while a 72-point trail follows the tip, which makes this a good base frame for any pendulum page. Scene 01 repainted 62.4% of the frame at an average intensity of 34.1 across 23/23 measured frames.

Pendulum Stepped by Solving Both Arm Accelerations
Open in a new tab

01 · Pendulum Stepped by Solving Both Arm Accelerations — Measured area 62.437% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

DTSUBSTEPSrk4StepaccTheta1accTheta2
      var sim = [START_T1, START_T2, 0, 0];
      var accA = 0, accB = 0;
      var trail = [];
      function stepFrame() {
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(sim, DT);
          }
          accA = accTheta1(sim[0], sim[1], sim[2], sim[3]);
          accB = accTheta2(sim[0], sim[1], sim[2], sim[3]);
          var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
          pushTrail(trail, p.x, p.y, TRAIL_LEN);
      }

02Tip Trail That Fades Like Ink

Each step pushes the tip position and its speed sp into the ink array and drops the oldest point past TRAIL_MAX 560. When drawing, every segment multiplies its alpha and width by (i / ink.length) raised to INK_FADE_POWER (1.6), so older lines thin out and the hue shifts where the tip moves fast. It pre-runs 150 frames, so a trail is already on screen when the page loads, and the scene repainted 62.4% at an average intensity of 31.4 across 23/23 frames.

Tip Trail That Fades Like Ink
Open in a new tab

02 · Tip Trail That Fades Like Ink — Measured area 62.405% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TRAIL_MAXINK_FADE_POWERL2rk4StepbobTwo
      function stepFrame() {
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(sim, DT);
              var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
              var sp = Math.abs(sim[3]) + Math.abs(sim[2]) * 0.5;
              ink.push({ x: p.x, y: p.y, sp: sp });
              if (ink.length > TRAIL_MAX) {
                  ink.shift();
              }
          }
          var q = bobOne(sim, PIVOT_X, PIVOT_Y);
          pushTrail(armTrail, q.x, q.y, 90);
      }
          ctx.lineCap = 'round';
          for (var i = 1; i < ink.length; i += 1) {
              var f = Math.pow(i / ink.length, INK_FADE_POWER);
              ctx.beginPath();
              ctx.moveTo(ink[i - 1].x, ink[i - 1].y);
              ctx.lineTo(ink[i].x, ink[i].y);
              ctx.strokeStyle = hsl(250 + ink[i].sp * 7 + i * 0.12, 78, 80, 0.9 * f);
              ctx.lineWidth = 0.5 + 3.4 * f;
              ctx.stroke();
          }

03Twin Pendulums Split by a Hair's Difference

restart() adds OFFSET (1e-6) to pendulum B's first angle only, advance() steps both the same way, and the gap between the two tips in UNIT is stacked in sepNow and drawn on a log-scale graph (logLevel). The status line changes to "The two pendulums split apart" on the first frame where the gap passes SPLIT_DIST 0.1, which the same equations place at 8.47 simulated seconds. To make the fork visible right away it pre-runs PREROLL_FRAMES 150 frames (6 simulated seconds) and then advances 0.04 seconds per frame (SUBSTEPS 4), and the scene repainted 62.9% at an average intensity of 36.3 across 23/23 frames.

Twin Pendulums Split by a Hair's Difference
Open in a new tab

03 · Twin Pendulums Split by a Hair's Difference — Measured area 62.895% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

OFFSETSPLIT_DISTSIM_RESET_FRAMESPREROLL_FRAMESlogLevel
      function advance() {
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(simA, DT);
              rk4Step(simB, DT);
          }
          var pa = bobTwo(simA, PIVOT_X, PIVOT_Y), pb = bobTwo(simB, PIVOT_X, PIVOT_Y);
          pushTrail(trailA, pa.x, pa.y, TRAIL_LEN);
          pushTrail(trailB, pb.x, pb.y, TRAIL_LEN);
          sepNow = Math.hypot(pa.x - pb.x, pa.y - pb.y) / UNIT;
          sepHist.push(sepNow);
          frameCount += 1;
          if (splitFrame < 0 && sepNow > SPLIT_DIST) {
              splitFrame = frameCount;
              setStatus('The two pendulums split apart');
          }
      }
      function restart() {
          simA = [START_T1, START_T2, 0, 0];
          simB = [START_T1 + OFFSET, START_T2, 0, 0];
          trailA = [];
          trailB = [];
          sepHist = [];
          frameCount = 0;
          splitFrame = -1;
          setStatus('Demo running');
          for (var i = 0; i < PREROLL_FRAMES; i += 1) {
              advance();
          }
      }

04Phase-Plane Trace Drawn from Both Angles

The first angle goes on the horizontal axis through planeX, the second on the vertical through planeY, and each step joins the previous point to the new one. wrapAngle folds angles into -π to π, so a segment that jumps across the edge (more than half of PLANE_SIZE) is skipped. An invisible offscreen canvas gets a dark rectangle at FADE_ALPHA 0.012 every frame to erase old lines, and the repaint area is only 32.4% here, yet the average intensity of 54.5 is high and 23/23 frames moved.

Phase-Plane Trace Drawn from Both Angles
Open in a new tab

04 · Phase-Plane Trace Drawn from Both Angles — Measured area 32.436% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FADE_ALPHAPLANE_SIZEwrapAngleplaneXplaneY
      function wrapAngle(a) {
          var r = (a + PI) % (2 * PI);
          if (r < 0) {
              r += 2 * PI;
          }
          return r - PI;
      }
      function planeX(a) { return (wrapAngle(a) + PI) / (2 * PI) * PLANE_SIZE; }
      function planeY(a) { return (PI - wrapAngle(a)) / (2 * PI) * PLANE_SIZE; }
      function stepFrame() {
          pg.fillStyle = 'rgba(29,26,43,' + FADE_ALPHA + ')';
          pg.fillRect(0, 0, PLANE_SIZE, PLANE_SIZE);
          pg.lineWidth = 2.4;
          pg.lineCap = 'round';
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(sim, DT);
              var px = planeX(sim[0]), py = planeY(sim[1]);
              if (hasLast && Math.abs(px - lastPx) < PLANE_SIZE / 2 && Math.abs(py - lastPy) < PLANE_SIZE / 2) {
                  pg.beginPath();
                  pg.moveTo(lastPx, lastPy);
                  pg.lineTo(px, py);
                  pg.strokeStyle = hsl(250 + elapsed / 14, 80, 78, 0.95);
                  pg.stroke();
              }
              lastPx = px;
              lastPy = py;
              hasLast = true;
          }
          var q = bobTwo(sim, PIVOT_X, PIVOT_Y);
          pushTrail(armTrail, q.x, q.y, 50);
      }

05Gauge Comparing How Far Each Method Drifts in Energy

From the same start it runs rk4Step and eulerStep (one jump using only the current slope) side by side, and totalEnergy with drift shows how far each is from the starting energy as bars and a percentage. Euler grows by +20.4% at 1 second, +61% at 3, +98% at 5 and +217% at 10, while RK4 stays within ±1e-4% at 10 seconds and the screen prints "-0.000%". The scene rewinds when Euler passes EULER_LIMIT_PCT 240 or reaches SIM_RESET_FRAMES 330, and it repainted 53.6% at an average intensity of 39.4 across 23/23 frames.

Gauge Comparing How Far Each Method Drifts in Energy
Open in a new tab

05 · Gauge Comparing How Far Each Method Drifts in Energy — Measured area 53.567% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

EULER_LIMIT_PCTBAR_FULL_PCTeulerSteptotalEnergydrift
      function eulerStep(s, h) {
          var a1 = accTheta1(s[0], s[1], s[2], s[3]);
          var a2 = accTheta2(s[0], s[1], s[2], s[3]);
          s[0] += h * s[2];
          s[1] += h * s[3];
          s[2] += h * a1;
          s[3] += h * a2;
      }
      // total energy = kinetic + potential (height is measured so straight down is 0)
      function totalEnergy(s) {
          var t1 = s[0], t2 = s[1], w1 = s[2], w2 = s[3];
          var kinetic = 0.5 * (M1 + M2) * L1 * L1 * w1 * w1 + 0.5 * M2 * L2 * L2 * w2 * w2
              + M2 * L1 * L2 * w1 * w2 * Math.cos(t1 - t2);
          var potential = -(M1 + M2) * G * L1 * Math.cos(t1) - M2 * G * L2 * Math.cos(t2);
          return kinetic + potential;
      }

06Sixteen Pendulums Fanning Out in a Band of Hues

COUNT 16 pendulums start with first angles spread by OFFSET_STEP 0.004, so the last one leans 0.06 radians more than the first. hueOf(i) turns the hue in proportion to the index from 330 degrees across HUE_SPAN 300, and each pendulum drags a TRAIL_LEN 26-point tip trail, so a rainbow leaving one point scatters and tangles. The scene restarts at SIM_RESET_FRAMES 340 and repainted 62.5% at an average intensity of 38.6 across 23/23 frames.

Sixteen Pendulums Fanning Out in a Band of Hues
Open in a new tab

06 · Sixteen Pendulums Fanning Out in a Band of Hues — Measured area 62.547% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

COUNTOFFSET_STEPHUE_SPANTRAIL_LENhueOf
      function restart() {
          fan = [];
          trails = [];
          for (var i = 0; i < COUNT; i += 1) {
              fan.push([START_T1 + OFFSET_STEP * i, START_T2, 0, 0]);
              trails.push([]);
          }
          frameCount = 0;
      }
      restart();
      function stepFrame() {
          for (var i = 0; i < COUNT; i += 1) {
              for (var k = 0; k < SUBSTEPS; k += 1) {
                  rk4Step(fan[i], DT);
              }
              var p = bobTwo(fan[i], PIVOT_X, PIVOT_Y);
              pushTrail(trails[i], p.x, p.y, TRAIL_LEN);
          }
          frameCount += 1;
          if (frameCount >= SIM_RESET_FRAMES) {
              restart();
          }
      }
      function hueOf(i) { return 330 + HUE_SPAN * i / (COUNT - 1); }

07Pendulum Whose Start Angles You Set by Dragging

When pointerdown lands near an arm tip (within GRAB_RADIUS 22, up to 1.6 times that far), held turns on and stepFrame() skips the integration while dragTo gets the angle with Math.atan2 and zeroes the speed. The arrow keys change the first angle (left and right) or the second (up and down) by KEY_ANGLE_STEP 0.12, and Space toggles hold and release. With nobody touching it, a fake pointer drags one of the four DEMO_POSES every DEMO_CYCLE_MS 4800 until a real input stops the demo, and the scene repainted 61.8% at an average intensity of 43.6 across 23/23 frames.

Pendulum Whose Start Angles You Set by Dragging
Open in a new tab

07 · Pendulum Whose Start Angles You Set by Dragging — Measured area 61.799% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRAB_RADIUSKEY_ANGLE_STEPPATH_STEPSDEMO_CYCLE_MSdragTo
      function stepFrame() {
          if (demo) {
              stepDemo();
          }
          if (held) {
              return;
          }
          for (var i = 0; i < SUBSTEPS; i += 1) {
              rk4Step(sim, DT);
          }
          var p = bobTwo(sim, PIVOT_X, PIVOT_Y);
          pushTrail(trail, p.x, p.y, TRAIL_LEN);
      }
      function dragTo(x, y) {
          if (grab === 1) {
              sim[0] = Math.atan2(x - PIVOT_X, y - PIVOT_Y);
          }
          else if (grab === 2) {
              var a = bobOne(sim, PIVOT_X, PIVOT_Y);
              sim[1] = Math.atan2(x - a.x, y - a.y);
          }
          sim[2] = 0;
          sim[3] = 0;
          trail = [];
          if (reduce) {
              makePreview();
          }
      }

08Start-Angle Map Colored by Time Until Flip

One cell of the GRID 48 map is one pair of start angles, and flipTime(t1, t2) runs a pendulum from rest in FLIP_DT 0.02 second steps for FLIP_STEPS 500 steps (10 seconds) and returns the first moment the second arm's angle passes π, or -1 if it never does. flipColor paints that time on a five-color log scale and uses a dark ink color for starts that never flip, so the result is a double pendulum fractal whose borders tangle into a complex pattern. It fills 36 cells per frame (CELLS_PER_FRAME), so 2304 cells take about 64 frames, and it repainted 43.2% at an average intensity of 57.0 across 23/23 frames.

Start-Angle Map Colored by Time Until Flip
Open in a new tab

08 · Start-Angle Map Colored by Time Until Flip — Measured area 43.201% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GRIDCELLS_PER_FRAMEFLIP_DTflipTimeflipColor
      function startOf(i) { return -PI + (i + 0.5) * 2 * PI / GRID; }
      function startOfRow(j) { return PI - (j + 0.5) * 2 * PI / GRID; }
      function flipTime(t1, t2) {
          var s = [t1, t2, 0, 0];
          for (var n = 0; n < FLIP_STEPS; n += 1) {
              rk4Step(s, FLIP_DT);
              if (Math.abs(s[1]) > PI) {
                  return (n + 1) * FLIP_DT;
              }
          }
          return -1;
      }
      function flipColor(t) {
          if (t < 0) {
              return [42, 36, 66];
          }
          var u = clamp(Math.log(1 + t * 3) / Math.log(1 + FLIP_STEPS * FLIP_DT * 3), 0, 0.999) * (STOPS.length - 1);
          var k = Math.floor(u), f = u - k;
          var c0 = STOPS[k], c1 = STOPS[k + 1];
          return [c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f];
      }

09Hero Scene Leaving a Glowing Trail on a Brass Desk Toy

brassSeg draws each arm in three layers (dark outline, brass body, highlight), brassBall draws a sphere with a radial gradient, and shadowOf pushes each joint's shadow sideways by SHADOW_SKEW (0.42) times its height h = DESK_Y - p.y, larger and fainter the higher it is. The tip trail piles up to GLOW_TRAIL_MAX 300 points with lighter compositing so it glows, which suits a landing hero or an about-page ornament. Scene 09 repainted 48.5% at an average intensity of 40.5 across 23/23 frames.

Hero Scene Leaving a Glowing Trail on a Brass Desk Toy
Open in a new tab

09 · Hero Scene Leaving a Glowing Trail on a Brass Desk Toy — Measured area 48.535% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

GLOW_TRAIL_MAXSHADOW_SKEWDESK_YshadowOfbrassBall
      function shadowOf(p, r) {
          var h = clamp(DESK_Y - p.y, 0, 200);
          var sx = p.x + h * SHADOW_SKEW;
          var sy = DESK_Y + 8 + h * 0.07;
          var k = 1 + h * 0.012;
          ctx.save();
          ctx.translate(sx, sy);
          ctx.scale(1, 0.28);
          ctx.beginPath();
          ctx.arc(0, 0, r * k, 0, 6.2832);
          ctx.fillStyle = 'rgba(8,6,16,' + (0.42 / k) + ')';
          ctx.fill();
          ctx.restore();
      }
      function brassSeg(x1, y1, x2, y2, w) {
          seg(x1, y1, x2, y2, w + 2, '#6f5630');
          seg(x1, y1, x2, y2, w, '#d4b077');
          var dx = x2 - x1, dy = y2 - y1;
          var len = Math.hypot(dx, dy) || 1;
          var nx = -dy / len, ny = dx / len;
          seg(x1 + nx * w * 0.22, y1 + ny * w * 0.22, x2 + nx * w * 0.22, y2 + ny * w * 0.22, w * 0.3, '#f6e7c1');
      }

Where the Values Change the Scene — Arm Length, Mass, Time Step

Three values change the look the most. The arm lengths L1 and L2 set the size and speed of the path the tip draws, the time step DT (with SUBSTEPS per frame) sets how far the calculation drifts from the real motion, and OFFSET in scene 03 sets when two pendulums part. The masses M1 and M2 stay at 1.0 here and I did not change them to measure anything.

The table collects the measurements for all nine scenes. Scene 03 is widest because two tips leave wide paths while a graph moves too, and scene 04 is narrowest because its picture lives inside a 192-pixel square plane. Every scene moved in all 23 frames.

Item Technique Repaint area Avg. intensity Moved frames
01 RK4 base pendulum 62.4% 34.1 23/23
02 Ink trail 62.4% 31.4 23/23
03 Twin divergence 62.9% 36.3 23/23
04 Phase plane 32.4% 54.5 23/23
05 Energy gauge 53.6% 39.4 23/23
06 Sixteen-pendulum fan 62.5% 38.6 23/23
07 Drag and release 61.8% 43.6 23/23
08 Flip-time map 43.2% 57.0 23/23
09 Brass hero 48.5% 40.5 23/23

Where It Breaks — Large Time Steps, Accumulated Energy Error, Narrow Screens

The time step breaks first. Raising DT in scene 01 from 0.01 to 0.05 lifts the RK4 energy error over 20 seconds from at most 0.03% to 13.5%, and 0.1 reaches 140.7%. The steps per frame stay the same, so the scene also runs five times faster, and trying to speed things up this way gives you a pendulum whose calculation has gone wrong. The cruder method in scene 05 already drifts by +20% after one second at the normal DT.

A narrow screen does not change the calculation. The canvas only scales by CSS, the calculation space stays fixed at CALC_W 320 by CALC_H 200, and the pointer in scene 07 is converted to that space as a ratio of the canvas size. No scene overflowed a phone width in the measurements. When you unpack the zip to try your own values, enter the password jep9f6eb to open it.

Three Variants and Accessibility

Variant Value Effect
Change the arm length ratio 02 L2 1.0 → 0.6 Shortening the second arm raises its peak angular speed from 13.6 to 20.7 rad/s over 15 simulated seconds, so the tip whirls faster, and the path length falls from 86 to 77 and packs into a tighter area.
Enlarge the time step 01 DT 0.01 → 0.05 Over the same 20 seconds the RK4 energy error grows from at most 0.03% to 13.5%, so the motion drifts from the real one, and the scene also runs five times faster.
Adjust the starting offset 03 OFFSET 1e-6 → 1e-3 The moment the gap between the tips passes 0.1 moves from 8.5 to 4.0 simulated seconds, so the split arrives almost twice as early.

With prefers-reduced-motion on, all nine scenes skip the requestAnimationFrame loop and instantly draw one still frame after the pre-run set by each scene's own PREROLL_FRAMES. In scenes 01 to 06 and 09, pressing the button, clicking the canvas, or hitting Enter or Space advances 40 frames at once and the status line says it is stepping scene by scene. Scenes 07 and 08 replace that button action and do not advance 40 frames, and their PREROLL_FRAMES is 0: scene 07 skips integration entirely and draws a dotted preview of the next 150 steps, and scene 08 computes all 2304 cells at once. Each canvas can take focus and has a button that works with the keyboard, and the status line is aria-live="polite".

Why Can't a Double Pendulum Be Predicted?

In this code the equations are deterministic, so identical start angles give an identical path every time. Unpredictable here means you can never know the start angle exactly, and double pendulum chaos theory comes down to that small difference being multiplied at every step. Scene 03 shows it in numbers: the gap between the tips is 1.8e-6 at 1 second, 4.7e-4 at 5, 1.5e-2 at 7 and about 1.2 at 9, growing by more than 30 times every 2 to 4 seconds until a millionth of a difference becomes as large as an arm. The tangled color borders in scene 08 come from the same effect, because a slightly different start flips at a different time.

So drawing the motion accurately far into the future is a problem of the start values, not of the calculation. For canvas scenes that work down to the pixel, see HTML5 Canvas Animation: 9 Warp & Droplet Effects, and for chaos built from a different kind of equation, see Lorenz Attractor: 9 Butterfly Chaos Demos. The drawing functions used here are documented in the Canvas API reference.

Frequently Asked Questions Before You Use These Pendulum Scenes

Can I run a double pendulum simulation on my own page without a library?

Yes. Open scene 07 to drag the start angles, then change START_T1 and START_T2 in the code. To see when the pair splits, raise the OFFSET in scene 03 from 1e-6 to 1e-3 and the moment the gap passes 0.1 moves from 8.47 to 4.04 seconds.

Do the scenes need external images or scripts?

No. All nine run on canvas 2d and one script in a single file, with no outside address and no random function, so copying the code gives you the same picture.

What do I change if the pendulum jumps or gains energy?

Lower DT or raise SUBSTEPS so each step is smaller. As the variant above showed, DT 0.05 gave an energy error of up to 13.5% while 0.01 gave 0.03%. Check the method too: switching to Euler as in scene 05 already adds +20% energy after the first second at the same DT.

Enter the archive password

The password is inside this article. You will find it as you read.