Pendulum Wave Symphony - xelsed.ai

This sketch recreates the classic physics 'pendulum wave' demonstration using 16 glowing colored balls that swing at slightly different speeds. Because each pendulum has a period tuned to complete one more swing than its neighbor over a 20-second cycle, the balls start perfectly aligned, drift into hypnotic wave and spiral patterns, then slowly re-align again.

🧪 Try This!

Experiment with the code by making these changes:

  1. Speed up time — Dividing millis() by a smaller number makes the shared clock run faster, so every pendulum swings quicker without touching individual periods.
  2. Reveal the pendulum strings — Uncommenting the stroke/line code shows the actual arm connecting each ball to its pivot point, turning the floating orbs back into recognizable pendulums.
  3. Add more pendulums — Raising NUM_PENDULUMS packs more balls into the same width, creating a denser, more intricate wave pattern.
  4. Make the balls bigger — Doubling the radius multiplier makes each glowing orb chunkier and more visually dominant.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch draws 16 colorful glowing orbs, each hanging from an invisible pendulum arm, swinging back and forth across a dark canvas. What makes it mesmerizing is that every pendulum swings at an almost-but-not-quite identical speed, so they start perfectly in sync, gradually spread into traveling wave shapes, and eventually snap back into alignment - the same effect used in real physical pendulum wave installations. The whole animation is built from just cosine math, HSB color, and a simple ES6 class, with no physics engine involved.

The code is organized around two setup helpers (setup() and initPendulums()) that calculate each pendulum's unique period, length, color and starting position, a draw() loop that ticks the animation forward every frame, and a Pendulum class whose update() and display() methods turn elapsed time into an on-screen position. Studying this sketch teaches you how to fake convincing periodic motion with cos(), how to derive many related values (period, length, hue) from a single index in a loop, and how HSB color mode makes rainbow gradients trivial.

⚙️ How It Works

  1. On load, setup() creates a full-window canvas, switches to HSB color mode, converts the maximum swing angle to radians, and calls initPendulums() to build the 16 Pendulum objects.
  2. initPendulums() first calculates a period for every pendulum so pendulum 0 completes 20 swings in the 20-second cycle, pendulum 1 completes 21 swings, and so on, then maps those periods to pendulum lengths (longer period = longer arm) and to a rainbow hue based on each pendulum's index.
  3. Every frame, draw() paints a near-black background and loops through the pendulums array calling update() then display() on each one.
  4. update() computes each pendulum's current swing angle using cos(phase), where phase depends on elapsed time divided by that pendulum's own unique period - since periods differ slightly, angles that started identical slowly drift apart.
  5. display() converts the pendulum's angle and arm length into x/y coordinates using sin() and cos() (polar-to-cartesian conversion), then draws a solid glowing circle plus a soft translucent halo at that spot.
  6. If the browser window is resized, windowResized() resizes the canvas and calls initPendulums() again so pendulum spacing and lengths stay proportional to the new screen size.

🎓 Concepts You'll Learn

Sinusoidal motion with cos()/sin()Polar to Cartesian coordinate conversionES6 classes and object-oriented animationHSB color mode for easy rainbow gradientsDeriving multiple values from a single loop index with map()Responsive canvases with windowResized()

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It's the right place to configure global settings like canvas size and color mode before anything gets drawn or calculated.

function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100, 100);
  maxAngle = radians(MAX_ANGLE_DEG);
  initPendulums();
}
Line-by-line explanation (4 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window, so the sketch always uses all available screen space.
colorMode(HSB, 360, 100, 100, 100);
Switches p5's color system to Hue-Saturation-Brightness with ranges 0-360 for hue and 0-100 for the rest, which makes generating a rainbow of colors as simple as changing one number (hue).
maxAngle = radians(MAX_ANGLE_DEG);
Converts the MAX_ANGLE_DEG constant (30 degrees) into radians, since p5's trig functions like cos() and sin() expect radians, not degrees.
initPendulums();
Calls the helper function that actually builds the array of Pendulum objects, keeping setup() short and readable.

initPendulums()

This function shows a powerful pattern: derive many related visual properties (length, color, position, speed) from a single loop index using map(). It's how you build organized visual variety instead of hardcoding 16 separate values.

🔬 This loop is what makes each pendulum drift out of phase - pendulum i always gets exactly one extra swing. What happens if you change 'MIN_SWINGS + i' to 'MIN_SWINGS + i * 2' so periods diverge twice as fast?

  for (let i = 0; i < NUM_PENDULUMS; i++) {
    const swings = MIN_SWINGS + i;          // each pendulum does one more swing
    const period = CYCLE_TIME / swings;     // seconds per full oscillation
    periods.push(period);
  }
function initPendulums() {
  pendulums = [];

  // First compute all periods based on desired cycle-time behavior
  const periods = [];
  for (let i = 0; i < NUM_PENDULUMS; i++) {
    const swings = MIN_SWINGS + i;          // each pendulum does one more swing
    const period = CYCLE_TIME / swings;     // seconds per full oscillation
    periods.push(period);
  }

  const minPeriod = Math.min(...periods);
  const maxPeriod = Math.max(...periods);

  // Lengths: longer pendulum → longer period (T ∝ sqrt(L))
  // We enforce L ∝ T² so motion looks physically plausible
  const minLen = height * 0.25;
  const maxLen = height * 0.70;

  const padX = width * 0.08;
  const spacingX = (width - 2 * padX) / (NUM_PENDULUMS - 1);
  const originY = height * 0.12;

  for (let i = 0; i < NUM_PENDULUMS; i++) {
    const period = periods[i];
    const length = map(
      period * period,
      minPeriod * minPeriod,
      maxPeriod * maxPeriod,
      minLen,
      maxLen
    );

    const originX = padX + i * spacingX;
    const hue = map(i, 0, NUM_PENDULUMS - 1, 0, 300); // rainbow-ish range

    pendulums.push(new Pendulum(originX, originY, length, period, hue));
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

for-loop Calculate Each Pendulum's Period const period = CYCLE_TIME / swings; // seconds per full oscillation

Gives pendulum i one more swing than pendulum i-1 during the cycle, which is the key trick that makes them slowly drift out of sync then back into sync

for-loop Build Each Pendulum Object pendulums.push(new Pendulum(originX, originY, length, period, hue));

Creates a Pendulum instance for every index, passing in its calculated x position, arm length, period, and rainbow hue

pendulums = [];
Empties the global pendulums array so this function can be safely re-run (for example on window resize) without duplicating pendulums.
const swings = MIN_SWINGS + i;
Each pendulum gets one extra swing compared to the previous one - pendulum 0 swings MIN_SWINGS times, pendulum 1 swings MIN_SWINGS+1 times, and so on.
const period = CYCLE_TIME / swings;
Divides the total cycle time by the number of swings to get how many seconds one full swing takes - more swings means a shorter (faster) period.
const minPeriod = Math.min(...periods);
Finds the smallest period in the whole array using the spread operator to pass all values into Math.min() at once.
const minLen = height * 0.25;
Sets the shortest pendulum arm to 25% of the canvas height, scaling the whole sketch nicely to any screen size.
const spacingX = (width - 2 * padX) / (NUM_PENDULUMS - 1);
Calculates even horizontal spacing between pendulums so they're evenly distributed across the canvas width, inside the left/right padding.
const length = map(period * period, minPeriod * minPeriod, maxPeriod * maxPeriod, minLen, maxLen);
Converts each pendulum's period into an arm length using the physical relationship T² ∝ L (period squared is proportional to length), so longer arms genuinely swing slower, just like real pendulums.
const hue = map(i, 0, NUM_PENDULUMS - 1, 0, 300);
Maps each pendulum's index to a hue value between 0 and 300 degrees on the color wheel, giving the row a smooth rainbow gradient from red through purple.
pendulums.push(new Pendulum(originX, originY, length, period, hue));
Creates a new Pendulum object with all its calculated properties and adds it to the pendulums array.

draw()

draw() runs about 60 times per second and is the heartbeat of any p5.js animation. Here it does the minimum necessary work each frame: clear the screen, get the current time, then delegate the actual per-pendulum math to the Pendulum class.

🔬 This loop drives the whole animation. What happens if you remove the p.update(t) call so pendulums only ever display() their very first angle (0)?

  for (const p of pendulums) {
    p.update(t);
    p.display();
  }
function draw() {
  // Dark background
  background(230, 40, 5); // HSB: near-black, slight tint

  const t = millis() / 1000; // time in seconds

  // Update and draw each pendulum
  for (const p of pendulums) {
    p.update(t);
    p.display();
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Update and Draw Every Pendulum for (const p of pendulums) {

Loops through the pendulums array each frame, recalculating each pendulum's swing angle and painting it at its new position

background(230, 40, 5); // HSB: near-black, slight tint
Repaints the entire canvas each frame with a very dark, slightly blue-tinted color (in HSB mode) so old pendulum positions from the previous frame are erased, preventing smearing.
const t = millis() / 1000; // time in seconds
millis() returns milliseconds since the sketch started; dividing by 1000 converts it to seconds, which is the unit the Pendulum.update() math expects.
for (const p of pendulums) {
A for...of loop that visits every Pendulum object in the array, one at a time, without needing an index variable.
p.update(t);
Tells the pendulum to recalculate its current swing angle based on the current time t.
p.display();
Tells the pendulum to draw itself at its (just updated) position on the canvas.

windowResized()

windowResized() is a special p5.js event function that's automatically called whenever the browser window changes size. Combining it with resizeCanvas() and re-running your setup logic is the standard way to build sketches that adapt to any screen.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  initPendulums();
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
A built-in p5.js function that resizes the canvas to match the browser window's new dimensions whenever it's resized.
initPendulums();
Rebuilds every pendulum from scratch using the new width/height, so spacing, lengths, and positions stay correctly proportioned to the new canvas size.

Pendulum constructor()

The constructor runs once per Pendulum object when it's first created with 'new Pendulum(...)'. It's where you set up all the starting properties (this.something) that other methods like update() and display() will read from later.

constructor(x, y, length, period, hue) {
    this.origin = createVector(x, y);
    this.length = length;
    this.period = period; // seconds
    this.hue = hue;

    this.maxAngle = maxAngle;
    this.angle = 0;

    // Bob radius scales with canvas size
    this.radius = min(width, height) * 0.02;
  }
Line-by-line explanation (5 lines)
this.origin = createVector(x, y);
Stores the fixed pivot point (the top of the pendulum arm) as a p5.Vector so it can be reused easily in position math later.
this.length = length;
Saves the arm length calculated back in initPendulums(), which determines how far the ball can swing from the origin.
this.period = period; // seconds
Stores how many seconds one full swing cycle takes for this specific pendulum - this is what makes every pendulum unique.
this.maxAngle = maxAngle;
Copies the global maxAngle (converted from MAX_ANGLE_DEG in setup()) onto this instance so update() can use it.
this.radius = min(width, height) * 0.02;
Sizes the ball relative to whichever canvas dimension (width or height) is smaller, so balls look proportionate on both wide and narrow screens.

Pendulum.update()

update() is called once per frame per pendulum and is a great example of stateless animation: instead of accumulating velocity over time, it directly computes the angle from the current time and period, so the motion is always perfectly smooth and never drifts due to frame rate hiccups.

🔬 This is the entire physics of the sketch in two lines. What happens if you multiply t by 3 before dividing by this.period - does the whole wave pattern speed up uniformly, or do relative differences between pendulums change too?

    const phase = (TWO_PI * t) / this.period;
    this.angle = this.maxAngle * cos(phase); // smooth ease-in/out at ends
update(t) {
    // Natural pendulum-like motion:
    // Use cosine so at t = 0 all start at the same extreme (same position)
    const phase = (TWO_PI * t) / this.period;
    this.angle = this.maxAngle * cos(phase); // smooth ease-in/out at ends
  }
Line-by-line explanation (2 lines)

🔧 Subcomponents:

calculation Phase and Angle Calculation this.angle = this.maxAngle * cos(phase); // smooth ease-in/out at ends

Converts elapsed time into a swing angle that smoothly eases at the extremes, mimicking real pendulum motion

const phase = (TWO_PI * t) / this.period;
Converts the elapsed time t into an angle in radians (using TWO_PI, p5's constant for a full circle) scaled by this pendulum's period - a faster period (smaller number) makes phase increase quicker.
this.angle = this.maxAngle * cos(phase); // smooth ease-in/out at ends
cos(phase) oscillates smoothly between -1 and 1; multiplying by maxAngle scales that into the pendulum's actual swing range, and because cosine naturally eases near its peaks, the motion looks like a real pendulum slowing down at the top of each swing.

Pendulum.display()

display() is where math becomes pixels: it takes the abstract angle from update() and turns it into an actual drawn shape. Layering a solid circle with a larger translucent one is a simple, reusable technique for faking glow effects without shaders.

🔬 These three lines are commented out on purpose to hide the pendulum strings. What happens if you uncomment them - do the visible arms make the wave pattern easier or harder to read?

    // stroke(this.hue, 30, 50, 20);
    // strokeWeight(1);
    // line(this.origin.x, this.origin.y, x, y);
display() {
    // Current bob position from polar coords
    const x = this.origin.x + this.length * sin(this.angle);
    const y = this.origin.y + this.length * cos(this.angle);

    // "Invisible string": do NOT draw the line
    // If you want a faint hint, uncomment below:
    // stroke(this.hue, 30, 50, 20);
    // strokeWeight(1);
    // line(this.origin.x, this.origin.y, x, y);

    // Glowing colorful ball
    noStroke();
    fill(this.hue, 80, 100, 100);
    circle(x, y, this.radius * 2);

    // Soft outer glow
    stroke(this.hue, 80, 100, 40);
    strokeWeight(this.radius * 0.6);
    noFill();
    circle(x, y, this.radius * 2.8);
  }
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Polar to Cartesian Conversion const x = this.origin.x + this.length * sin(this.angle);

Converts the pendulum's angle and arm length into an actual x/y pixel position using trigonometry

calculation Draw Solid Ball Plus Soft Glow circle(x, y, this.radius * 2.8);

Draws a bigger, low-opacity outlined circle on top of the solid ball to fake a soft glowing halo

const x = this.origin.x + this.length * sin(this.angle);
Standard polar-to-cartesian conversion: sin(angle) gives the horizontal offset from the origin, scaled by the arm length, so the ball moves left/right as the angle changes.
const y = this.origin.y + this.length * cos(this.angle);
cos(angle) gives the vertical offset - at angle 0 the ball hangs straight down at maximum distance; as the angle grows, y decreases slightly (the ball rises as it swings sideways).
noStroke();
Turns off outlines so the solid ball is drawn as a clean filled shape with no border.
fill(this.hue, 80, 100, 100);
Sets the fill color using this pendulum's unique hue with high saturation and brightness and full opacity (100), producing a vivid, fully opaque colored ball.
circle(x, y, this.radius * 2);
Draws the solid ball at the computed position; circle()'s third argument is diameter, so radius is doubled.
stroke(this.hue, 80, 100, 40);
Sets a semi-transparent (alpha 40 out of 100) stroke color in the same hue, used to fake a soft glow around the ball.
strokeWeight(this.radius * 0.6);
Makes the outline thick relative to the ball's size, so it reads as a soft halo rather than a thin ring.
circle(x, y, this.radius * 2.8);
Draws a slightly larger circle with no fill, just the thick translucent stroke, layered on top of the solid ball to create the glow effect.

📦 Key Variables

pendulums array

Holds all 16 Pendulum objects; draw() loops over this array every frame to update and render each one

let pendulums = [];
NUM_PENDULUMS number

How many pendulum balls are created and animated

const NUM_PENDULUMS = 16;
CYCLE_TIME number

Total seconds for the pendulums to drift apart and roughly re-align, used to derive each pendulum's period

const CYCLE_TIME = 20;
MIN_SWINGS number

Number of swings the slowest (first) pendulum makes during one cycle; later pendulums each get one more

const MIN_SWINGS = 20;
MAX_ANGLE_DEG number

The maximum angle (in degrees) any pendulum swings away from straight down

const MAX_ANGLE_DEG = 30;
maxAngle number

MAX_ANGLE_DEG converted to radians so it can be used directly with p5's trig functions like cos()

let maxAngle;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG initPendulums()

spacingX is calculated as (width - 2 * padX) / (NUM_PENDULUMS - 1), which divides by zero (producing Infinity) if NUM_PENDULUMS is ever set to 1, and could produce a negative or tiny spacing on very narrow windows since padX is a fixed fraction of width regardless of NUM_PENDULUMS.

💡 Guard against NUM_PENDULUMS <= 1 with an early return or special case, and consider clamping padX so pendulums never overlap on narrow screens.

STYLE draw() and Pendulum.display()

HSB color values like background(230, 40, 5) and fill(this.hue, 80, 100, 100) use unexplained magic numbers scattered through the code, making it harder to tune the palette consistently.

💡 Extract shared values like saturation and brightness into named constants (e.g. BALL_SAT = 80, BALL_BRIGHT = 100) declared near the top of the file so the whole palette can be adjusted in one place.

PERFORMANCE initPendulums()

This function fully rebuilds the pendulums array (and recalculates every period, length, and hue) on every single window resize event, which can fire many times rapidly while a user is dragging to resize.

💡 Debounce windowResized() with a short setTimeout so initPendulums() only runs once resizing has paused, avoiding redundant recalculation during continuous drags.

FEATURE draw() / mousePressed

The sketch is currently non-interactive - it just plays on a loop with no way for a viewer to influence the pattern.

💡 Add a mousePressed() handler that lets users click to add a temporary 'kick' to a pendulum's phase, or drag horizontally to speed up/slow down global time, making the demo more engaging.

🔄 Code Flow

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

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

graph TD start[Start] --> setup[setup] setup --> initpendulums[initpendulums] initpendulums --> draw[draw loop] draw --> periods-loop[Calculate Each Pendulum's Period] periods-loop --> pendulum-creation-loop[Build Each Pendulum Object] pendulum-creation-loop --> pendulum-update-loop[Update and Draw Every Pendulum] pendulum-update-loop --> phase-calculation[Phase and Angle Calculation] phase-calculation --> bob-position[Polar to Cartesian Conversion] bob-position --> glow-effect[Draw Solid Ball Plus Soft Glow] glow-effect --> draw draw -->|60 times per second| draw windowresized[windowResized] --> resizeCanvas[resizeCanvas] resizeCanvas --> setup click setup href "#fn-setup" click initpendulums href "#fn-initpendulums" click draw href "#fn-draw" click periods-loop href "#sub-periods-loop" click pendulum-creation-loop href "#sub-pendulum-creation-loop" click pendulum-update-loop href "#sub-pendulum-update-loop" click phase-calculation href "#sub-phase-calculation" click bob-position href "#sub-bob-position" click glow-effect href "#sub-glow-effect"

❓ Frequently Asked Questions

What visual effects does the Pendulum Wave Symphony create?

The sketch features 16 colorful balls swinging in a mesmerizing wave pattern, drifting in and out of phase to produce hypnotic and relaxing visuals.

Is there any way for users to interact with the Pendulum Wave Symphony sketch?

The sketch is primarily a visual experience without interactive elements; users can enjoy the dynamic wave patterns as they unfold.

What creative coding concepts are demonstrated in the Pendulum Wave Symphony?

This sketch illustrates sinusoidal motion and principles of physics, such as pendulum dynamics and varying periods to create visually engaging wave patterns.

Preview

Pendulum Wave Symphony - xelsed.ai - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Pendulum Wave Symphony - xelsed.ai - Code flow showing setup, initpendulums, draw, windowresized, constructor, update, display
Code Flow Diagram