AI Pendulum Wave - Mesmerizing Physics Simulation Watch multiple pendulums of varying lengths swing

This sketch simulates a classic pendulum wave: dozens of pendulums with carefully calculated lengths swing from a shared anchor point, drifting in and out of sync to create hypnotic, ever-shifting wave patterns. Glowing HSB-colored trails fade slowly on an offscreen buffer, turning simple physics into a flowing light painting that responds live to gravity and pendulum-count sliders.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make trails fade faster — Raising the trail rectangle's alpha value erases old pendulum positions more quickly, producing shorter, snappier trails.
  2. Settle the swing faster — Lowering the damping factor drains energy from the pendulums more aggressively, so the wave pattern winds down and stops sooner.
  3. Give every pendulum bigger bobs — Increasing the bob diameter turns the delicate wave into a bolder, chunkier pattern of glowing dots.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch recreates the famous 'pendulum wave' physics demonstration: a row of pendulums with precisely tuned lengths swing from the same point, and because their periods are mathematically related, they drift in and out of phase to form traveling wave patterns, briefly synchronize, then scatter again. Each pendulum leaves a glowing, slowly-fading trail thanks to an offscreen graphics buffer and HSB color mode, turning raw trigonometry into a hypnotic light show. The whole thing is interactive - two sliders let you change how many pendulums swing and how strong gravity is, and the simulation rebuilds itself instantly.

The code is organized around a global draw loop that drives a custom Pendulum class: each pendulum tracks its own angle, angular velocity, and length, and knows how to update its own physics and draw itself. Sliders trigger an initializePendulums() function that recalculates every pendulum's length using a wave formula, while an offscreen createGraphics() buffer accumulates fading trails separately from the main canvas. Studying this sketch teaches you simple harmonic motion physics, object-oriented sketch design with classes, HSB color gradients, and the fade-trail technique used in countless generative art pieces.

⚙️ How It Works

  1. On load, setup() creates the main canvas plus a second offscreen 'trail' graphics buffer, sets HSB color mode on both, and builds two sliders for pendulum count and gravity.
  2. initializePendulums() runs immediately (and whenever the count slider changes) - it empties the pendulums array and refills it, giving each pendulum a length calculated from a wave formula so all pendulums complete whole-number cycles in the same total time.
  3. Every frame, draw() reads the current gravity slider value, checks whether the pendulum count changed (re-initializing if so), and paints a nearly-transparent black rectangle over the trail buffer so old trails fade out gradually instead of vanishing instantly.
  4. Still inside draw(), the code loops over every pendulum calling update() then display() - update() applies the pendulum equation of motion (angular acceleration from gravity and length, then velocity, then angle, with damping to slowly settle the swing) and display() converts the angle into an x/y bob position and draws the rod and bob onto the trail buffer.
  5. The trail buffer is stamped onto the main canvas with image(), and a small white anchor circle is drawn on top so the pivot point is always visible.
  6. windowResized() keeps both canvases matched to the browser window size and rebuilds the pendulums so the wave pattern still fits and looks correct after resizing.

🎓 Concepts You'll Learn

Simple harmonic motion physicsObject-oriented design with ES6 classesOffscreen graphics buffers (createGraphics)HSB color mode and gradientsFade-trail rendering techniqueDOM sliders and event callbacksResponsive canvas resizing

📝 Code Breakdown

setup()

setup() runs once at the start of the sketch. Here it prepares two canvases (visible and offscreen), builds the DOM sliders, and kicks off the first pendulum layout.

function setup() {
  // Create the main canvas
  createCanvas(windowWidth, windowHeight);
  // Use HSB color mode for easy gradient generation
  colorMode(HSB, 360, 100, 100, 100);

  // Create the offscreen graphics buffer for trails
  trailGraphics = createGraphics(windowWidth, windowHeight);
  trailGraphics.colorMode(HSB, 360, 100, 100, 100);
  // Fill the trail buffer with a dark background initially
  trailGraphics.background(0, 0, 0, 100);

  // --- Create Sliders ---

  // Pendulum Count Slider
  pendulumCountLabel = createP('Pendulum Count: ');
  pendulumCountLabel.position(10, 10);
  pendulumCountLabel.class('p5js_label'); // Add class for styling
  pendulumCountSlider = createSlider(5, 50, 25, 1); // Min 5, Max 50, Initial 25, Step 1
  pendulumCountSlider.position(10, 40);
  pendulumCountSlider.class('p5js_slider'); // Add class for styling
  // Re-initialize pendulums whenever the slider changes
  pendulumCountSlider.input(initializePendulums);

  // Gravity Slider
  gravityLabel = createP('Gravity: ');
  gravityLabel.position(10, 70);
  gravityLabel.class('p5js_label'); // Add class for styling
  gravitySlider = createSlider(0.1, 2, g, 0.01); // Min 0.1, Max 2, Initial g, Step 0.01
  gravitySlider.position(10, 100);
  gravitySlider.class('p5js_slider'); // Add class for styling

  // Set the initial pendulum count and initialize them
  currentPendulumCount = pendulumCountSlider.value();
  initializePendulums();
}
Line-by-line explanation (8 lines)
createCanvas(windowWidth, windowHeight);
Makes the main visible canvas fill the whole browser window.
colorMode(HSB, 360, 100, 100, 100);
Switches to Hue-Saturation-Brightness color mode so colors can be picked by rotating a hue value from 0-360, which is perfect for rainbow gradients.
trailGraphics = createGraphics(windowWidth, windowHeight);
Creates a second, invisible canvas the same size as the window - this buffer will hold the fading pendulum trails separately from the main canvas.
trailGraphics.background(0, 0, 0, 100);
Fills the trail buffer with a fully opaque black background so it starts clean.
pendulumCountSlider = createSlider(5, 50, 25, 1);
Creates a slider ranging from 5 to 50 pendulums, starting at 25, moving in steps of 1.
pendulumCountSlider.input(initializePendulums);
Registers initializePendulums as the callback that runs every time the user drags this slider.
gravitySlider = createSlider(0.1, 2, g, 0.01);
Creates a finer slider for gravity, from 0.1 to 2, starting at the current value of g, moving in 0.01 steps.
initializePendulums();
Builds the initial set of pendulum objects before the first frame is drawn.

initializePendulums()

This function is the 'setup script' for the wave pattern itself - it's called once at start and again any time the pendulum count changes, so the sketch can rebuild its physics objects on demand.

🔬 This loop's formula is what makes the pendulums fall in and out of sync. What happens visually if you change the exponent from 2 to 1, making lengths grow linearly instead of quadratically?

  for (let i = 0; i < currentPendulumCount; i++) {
    let N = currentPendulumCount;
    let L = L_shortest * pow(N / (N - i), 2);
    // Create a new Pendulum object and add it to the array
    pendulums.push(new Pendulum(L, g, damping, cx, cy, i, N));
  }
function initializePendulums() {
  pendulums = []; // Clear existing pendulums
  currentPendulumCount = pendulumCountSlider.value();
  // Update the label to show the current pendulum count
  pendulumCountLabel.html('Pendulum Count: ' + currentPendulumCount);

  // Clear trails on the offscreen buffer when pendulums are re-initialized
  trailGraphics.background(0, 0, 0, 100);

  // Anchor point for all pendulums (top center of the canvas)
  let cx = width / 2;
  let cy = height / 4;

  // Pendulum wave formula: L_i = L_shortest * (N / (N - i))^2
  // This formula ensures that all pendulums complete an integer number of cycles
  // in the same total time, causing them to periodically fall in and out of phase.
  // 'i' goes from 0 to N-1, where i=0 is the shortest pendulum.
  for (let i = 0; i < currentPendulumCount; i++) {
    let N = currentPendulumCount;
    let L = L_shortest * pow(N / (N - i), 2);
    // Create a new Pendulum object and add it to the array
    pendulums.push(new Pendulum(L, g, damping, cx, cy, i, N));
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Pendulum Length Calculation Loop for (let i = 0; i < currentPendulumCount; i++) {

Calculates a unique length for each pendulum using the wave formula and creates a new Pendulum object for it

pendulums = []; // Clear existing pendulums
Wipes the array so old pendulum objects are discarded before rebuilding.
currentPendulumCount = pendulumCountSlider.value();
Reads the latest slider value so the rest of the function uses the up-to-date count.
trailGraphics.background(0, 0, 0, 100);
Clears all existing trails so old pendulum paths don't linger when the layout changes.
let L = L_shortest * pow(N / (N - i), 2);
The heart of the pendulum wave effect - each pendulum's length is scaled so that all of them complete a whole number of swings in the same total time window, which is what causes them to fall in and out of sync.
pendulums.push(new Pendulum(L, g, damping, cx, cy, i, N));
Creates a new Pendulum object with its calculated length and adds it to the array that draw() will loop through.

draw()

draw() is the sketch's animation heartbeat, running ~60 times per second. It combines live UI reads, physics updates, and the offscreen-buffer fade trick to keep the whole wave animating smoothly.

🔬 This loop updates and draws every pendulum once per frame. What happens if you call p.update(g) twice in a row before display() - does the wave speed up?

  for (let p of pendulums) {
    p.update(g); // Update pendulum's position based on physics and current gravity
    p.display(trailGraphics); // Draw the pendulum on the trail graphics buffer
  }
function draw() {
  // Update gravity from the slider
  g = gravitySlider.value();
  // Update the label to show the current gravity value
  gravityLabel.html('Gravity: ' + g.toFixed(2));

  // Check if the pendulum count slider value has changed.
  // If it has, re-initialize the pendulums.
  if (pendulumCountSlider.value() !== currentPendulumCount) {
    initializePendulums();
  }

  // --- Trail Drawing ---
  // Draw a very transparent black rectangle over the trail buffer.
  // This makes existing trails gradually fade out, creating the trailing effect.
  trailGraphics.noStroke();
  trailGraphics.fill(0, 0, 0, 1); // Low alpha value for long trails
  trailGraphics.rect(0, 0, width, height);

  // --- Pendulum Simulation and Drawing ---
  // Loop through each pendulum, update its physics, and draw it
  for (let p of pendulums) {
    p.update(g); // Update pendulum's position based on physics and current gravity
    p.display(trailGraphics); // Draw the pendulum on the trail graphics buffer
  }

  // Display the accumulated trails from the offscreen buffer onto the main canvas
  image(trailGraphics, 0, 0);

  // Draw a small circle at the anchor point of the pendulums
  noStroke();
  fill(360, 0, 100); // White color
  circle(width / 2, height / 4, 10); // Diameter 10
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Slider Change Check if (pendulumCountSlider.value() !== currentPendulumCount) {

Detects when the user has dragged the pendulum count slider and rebuilds the pendulums array

for-loop Pendulum Update & Draw Loop for (let p of pendulums) {

Steps every pendulum's physics forward one frame and draws it onto the trail buffer

g = gravitySlider.value();
Reads the current gravity slider value every frame so changes take effect immediately.
if (pendulumCountSlider.value() !== currentPendulumCount) {
A safety-net check comparing the slider's live value to the last known count, so the pendulums rebuild even if the input event was missed.
trailGraphics.fill(0, 0, 0, 1); // Low alpha value for long trails
Sets a nearly invisible black fill - painting a full-screen rectangle with this low alpha slightly darkens old trails each frame instead of erasing them instantly.
trailGraphics.rect(0, 0, width, height);
Draws that faint black rectangle over the whole trail buffer, which is what creates the gradual fade-out effect for older pendulum positions.
p.update(g);
Calls the pendulum's own physics update method, passing in the current gravity value.
p.display(trailGraphics);
Tells the pendulum to draw its rod and bob onto the trail buffer (not directly onto the main canvas).
image(trailGraphics, 0, 0);
Copies the entire trail buffer - with its fading history of pendulum swings - onto the visible canvas.
circle(width / 2, height / 4, 10); // Diameter 10
Draws a small white dot marking the shared anchor point that all pendulums swing from.

Pendulum constructor()

The constructor runs once per pendulum when it's created, setting up its starting physics state and its unique color based on where it sits in the row.

  constructor(L, g, damping, cx, cy, index, totalPendulums) {
    this.L = L; // Length of the pendulum
    this.g = g; // Gravity
    this.damping = damping; // Damping factor
    this.cx = cx; // Anchor x-coordinate
    this.cy = cy; // Anchor y-coordinate

    this.theta = PI / 4; // Initial angle from vertical (45 degrees)
    this.omega = 0; // Initial angular velocity
    this.alpha = 0; // Initial angular acceleration

    // Assign a color based on its index in the array for a gradient effect
    this.color = color(map(index, 0, totalPendulums, 0, 360), 80, 80);
  }
Line-by-line explanation (4 lines)
this.L = L; // Length of the pendulum
Stores this pendulum's unique length, which determines how fast it swings (longer pendulums swing more slowly).
this.theta = PI / 4; // Initial angle from vertical (45 degrees)
Every pendulum starts pulled to the same 45-degree angle, so they all begin swinging together before drifting apart.
this.omega = 0; // Initial angular velocity
Starts each pendulum at rest (no initial swing speed) - gravity alone will set it in motion.
this.color = color(map(index, 0, totalPendulums, 0, 360), 80, 80);
Maps this pendulum's position in the array (0 to totalPendulums) onto the 0-360 hue range, so the shortest pendulum is one color and the longest is another, creating a rainbow gradient across the row.

update()

update() implements simple harmonic motion using Euler integration - a common, simple way to simulate physics frame by frame in creative coding.

🔬 This is the physics core of the whole sketch. What happens if you swap sin(this.theta) for this.theta itself (the small-angle approximation)? Would the wave pattern still work at large starting angles?

    this.alpha = (-this.g / this.L) * sin(this.theta);
    // Update angular velocity
    this.omega += this.alpha;
    // Update angle
    this.theta += this.omega;
  update(newG) {
    this.g = newG; // Update gravity from the slider
    // Calculate angular acceleration
    this.alpha = (-this.g / this.L) * sin(this.theta);
    // Update angular velocity
    this.omega += this.alpha;
    // Update angle
    this.theta += this.omega;
    // Apply damping
    this.omega *= this.damping;
  }
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Angular Acceleration Formula this.alpha = (-this.g / this.L) * sin(this.theta);

Applies the simple pendulum physics equation to compute how quickly the swing angle is accelerating

this.g = newG;
Updates this pendulum's gravity to match the current slider value, so all pendulums instantly respond to gravity changes.
this.alpha = (-this.g / this.L) * sin(this.theta);
This is the real pendulum physics equation - acceleration is proportional to gravity divided by length, times the sine of the current angle, and negative because gravity always pulls back toward vertical.
this.omega += this.alpha;
Integrates acceleration into velocity - each frame, the swing speeds up or slows down based on the current acceleration.
this.theta += this.omega;
Integrates velocity into angle - this is what actually moves the pendulum each frame.
this.omega *= this.damping;
Multiplies velocity by a number slightly less than 1, slowly bleeding off energy to simulate air resistance so the swing doesn't go on forever.

display()

display() converts pure physics numbers (an angle) into pixel coordinates using sin() and cos() - a foundational trick used anywhere rotation or swinging motion needs to be drawn.

  display(graphics) {
    // Calculate the position of the pendulum bob
    let bobX = this.cx + this.L * sin(this.theta);
    let bobY = this.cy + this.L * cos(this.theta);

    // Draw the pendulum rod
    graphics.stroke(this.color); // Use the pendulum's assigned color
    graphics.strokeWeight(1);
    graphics.line(this.cx, this.cy, bobX, bobY);

    // Draw the pendulum bob
    graphics.noStroke();
    graphics.fill(this.color, 150); // Slightly more opaque color for the bob
    graphics.circle(bobX, bobY, 12); // Bob diameter 12
  }
Line-by-line explanation (5 lines)
let bobX = this.cx + this.L * sin(this.theta);
Converts the pendulum's swing angle into an actual x pixel position using trigonometry - sin() gives the horizontal offset from the anchor.
let bobY = this.cy + this.L * cos(this.theta);
Uses cos() to find how far down from the anchor the bob currently hangs, completing the angle-to-position conversion.
graphics.line(this.cx, this.cy, bobX, bobY);
Draws the rigid rod connecting the fixed anchor point to the moving bob position.
graphics.fill(this.color, 150); // Slightly more opaque color for the bob
Fills the bob with the pendulum's assigned hue at a semi-transparent alpha, letting trails blend nicely underneath.
graphics.circle(bobX, bobY, 12); // Bob diameter 12
Draws the pendulum's weight (bob) as a small circle at its calculated position.

windowResized()

windowResized() is a special p5.js function that's automatically called whenever the browser window changes size, letting you keep responsive sketches looking correct.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  trailGraphics.resizeCanvas(windowWidth, windowHeight);
  trailGraphics.background(0, 0, 0, 100); // Clear trails on resize
  initializePendulums(); // Re-initialize pendulums based on new canvas size
}
Line-by-line explanation (4 lines)
resizeCanvas(windowWidth, windowHeight);
A built-in p5.js function that resizes the main canvas whenever the browser window changes size.
trailGraphics.resizeCanvas(windowWidth, windowHeight);
Resizes the offscreen trail buffer to match, since it must always be the same size as the main canvas for image() to align correctly.
trailGraphics.background(0, 0, 0, 100);
Clears the trail buffer since resizing a graphics buffer can leave old pixel data misplaced.
initializePendulums();
Rebuilds the pendulums using the anchor point calculated from the new width/height so the wave stays centered and proportional.

📦 Key Variables

pendulums array

Holds all the Pendulum objects currently being simulated and drawn each frame.

let pendulums = [];
trailGraphics object

An offscreen p5.Graphics buffer used to accumulate and gradually fade the pendulum trails separately from the main canvas.

let trailGraphics;
g number

The current gravity value used in the pendulum physics equation, live-updated from the gravity slider.

let g = 0.8;
L_shortest number

The base length used to calculate every pendulum's length via the wave formula.

let L_shortest = 80;
damping number

A factor slightly less than 1 applied to angular velocity each frame to simulate air resistance and slowly settle the swings.

let damping = 0.9995;
pendulumCountSlider object

The DOM slider element controlling how many pendulums are simulated.

let pendulumCountSlider;
gravitySlider object

The DOM slider element controlling the gravity value used in the physics simulation.

let gravitySlider;
pendulumCountLabel object

A text label element displaying the current pendulum count above its slider.

let pendulumCountLabel;
gravityLabel object

A text label element displaying the current gravity value above its slider.

let gravityLabel;
currentPendulumCount number

Tracks the last-known pendulum count so draw() can detect when the slider value has changed and rebuild the pendulums.

let currentPendulumCount = 0;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

STYLE draw() and setup()

Both the slider's own .input(initializePendulums) callback and a manual value comparison inside draw() can trigger a rebuild of the pendulums, which is redundant logic that could confuse future readers.

💡 Pick one mechanism - either rely solely on the input() event callback, or remove it and keep only the draw() polling check - to make the control flow clearer.

PERFORMANCE draw()

gravityLabel.html() and pendulumCountLabel.html() are DOM operations that run every single frame even when the values haven't changed, which is more expensive than plain canvas drawing.

💡 Only update the label's HTML when the displayed value actually changes, e.g. by comparing against a stored previous value first.

FEATURE Pendulum class / draw()

There's no way to interact directly with the pendulums themselves - the only controls are count and gravity sliders.

💡 Add a mousePressed() handler that lets users click-drag the nearest pendulum bob to manually displace it and watch it rejoin the wave, which would make the physics feel more tangible.

BUG Pendulum constructor

The constructor stores this.g from its parameter, but update() immediately overwrites this.g with newG every frame, making the constructor's g argument effectively unused after the first frame.

💡 Either remove the redundant g parameter from the constructor or use it meaningfully (e.g. as a per-pendulum gravity offset) to avoid dead code that misleads readers.

🔄 Code Flow

Code flow showing setup, initializependulums, draw, constructor, update, display, windowresized

💡 Click on function names in the diagram to jump to their code

graph TD start[Start] --> setup[setup] setup --> initializependulums[initializependulums] setup --> draw[draw loop] draw --> countchangecheck[count-change-check] draw --> pendulumupdateloop[pendulum-update-loop] countchangecheck -->|User drags slider| initializependulums pendulumupdateloop --> angularaccelcalc[angular-accel-calc] pendulumupdateloop --> display[display] angularaccelcalc --> update[update] update --> pendulumupdateloop click setup href "#fn-setup" click initializependulums href "#fn-initializependulums" click draw href "#fn-draw" click countchangecheck href "#sub-count-change-check" click pendulumupdateloop href "#sub-pendulum-update-loop" click angularaccelcalc href "#sub-angular-accel-calc" click update href "#fn-update" click display href "#fn-display"

❓ Frequently Asked Questions

What visual effects can I expect from the AI Pendulum Wave sketch?

The sketch visually simulates multiple pendulums of varying lengths swinging in a mesmerizing wave-like pattern, enhanced by colorful trails that create a dynamic and captivating display.

How can I customize my experience in the pendulum simulation?

Users can interact with the sketch by adjusting sliders to change the number of pendulums and the gravity effect, allowing for personalized visual outcomes.

What creative coding concepts does this pendulum simulation illustrate?

This sketch demonstrates concepts of physics simulation, such as pendulum motion and damping effects, combined with visual design techniques like color gradients and trails.

Preview

AI Pendulum Wave - Mesmerizing Physics Simulation Watch multiple pendulums of varying lengths swing - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Pendulum Wave - Mesmerizing Physics Simulation Watch multiple pendulums of varying lengths swing - Code flow showing setup, initializependulums, draw, constructor, update, display, windowresized
Code Flow Diagram