Animation and Randomness

Randomness gives every object a different value; animation changes values over time. This page shows how to keep the random differences between objects while animating them: when to compute a value, when to retain it, and when to update it.

Animate a Property with Time

let clearEachFrame = true;

function draw() {
  if (clearEachFrame) {
    background(100);
  }

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    let y = 100 + 20 * sin(i + millis() / 100);
    circle(x, y, 20);
  }
}
Explore more parameters

The sketches on this page are live and editable. Drag an underlined number left or right to change it, or focus it and press Up or Down (hold Shift for larger steps). The underlined values address the current example; the Explore more parameters disclosure below a sketch contains additional settings for that same sketch. The round-arrow button restores the original values, and each live sketch’s Copy button produces a complete sketch you can paste into the p5.js editor.

setup() runs once when the sketch starts; draw() then runs again and again, about sixty times per second. This sketch animates by making the vertical position a function of time: millis() returns the milliseconds since the sketch started, and dividing it slows the change to a visible pace.

What remains from the previous frame? Whatever the last draw() left on the canvas. The highlighted background(100) erases it at the start of each frame. Uncheck Clear each frame to see the trail the circles paint when nothing erases.

Try it: animate the size of the circles instead of their position. That is a structural change, not a number edit, so here is a complete sketch to copy and adapt.

A complete example that animates size instead of position
function setup() {
  createCanvas(500, 200);
}

function draw() {
  background(100);

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    let size = 10 + 10 * sin(i + millis() / 100);
    circle(x, 100, size);
  }
}

Fresh Random Values versus Retained

random() is another way to make a property differ: every call returns a new number. Whether that helps depends on when the sketch asks. The three views below start from the same twenty random values. Press Play or Step one frame, and watch which view changes.

Seed 42 · Frame 0 · 0.00 simulated s
Draw once one draw() call, then nothing
Sample every frame random() again on every frame
Remember positions sample stored in setup(), reused each frame

All three views start from the same twenty values. Playback advances every view through the same frames at 30 frames per second and stops after 300 frames (10 simulated seconds; the clock counts frames, not wall time), but only Sample every frame asks for new values. Draw once and Remember positions stay fixed: the first stops redrawing, the second retains the data and redraws it. Restart replays the same seed; Generate again moves to the next seed with a fresh shared sample. After the last frame, Play becomes Replay.

p5.js code: Draw once
function setup() {
  createCanvas(500, 200);
  randomSeed(7);
  background(100);

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    let y = random(80, 120);
    circle(x, y, 20);
  }
}
p5.js code: Sample every frame
function setup() {
  createCanvas(500, 200);
  randomSeed(7);
}

function draw() {
  background(100);

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    let y = random(80, 120);
    circle(x, y, 20);
  }
}
p5.js code: Remember positions
let ys = new Array();

function setup() {
  createCanvas(500, 200);
  randomSeed(7);
  for (let i = 0; i < 20; i++) {
    ys[i] = random(80, 120);
  }
}

function draw() {
  background(100);

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    circle(x, ys[i], 20);
  }
}

Each sketch is complete and runnable. They use randomSeed(7) so a run can be replayed; the canvases above use their own seed shown in the status line, so the exact values differ but the behavior matches.

Only the middle view asks for new values. Draw once never asks again, because it stops redrawing. Remember positions asks once and keeps the answers, so every frame can reuse them. The first and third views look identical here; the difference matters as soon as the drawing changes. The pixels from Draw once stay on the canvas until something draws over them, but the coordinates that produced them were discarded. Retained values can be redrawn after clearing and combined with other animation, frame after frame.

Store Values in an Array

An array can remember the vertical position of each circle. Declare ys outside the functions so that setup() and draw() share it. Fill it in setup(), which runs once; each call to draw() then reads the same stored positions.

let ys = new Array();

function setup() {
  createCanvas(windowWidth, windowHeight);
  for (let i = 0; i < 20; i++) {
    ys[i] = random(80, 120);
  }
}

function draw() {
  background(100);

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    circle(x, ys[i], 20);
  }
}
Explore more parameters

This sketch still calls draw() every frame, but the drawing no longer flickers, because nothing in draw() asks for new values.

Common pitfall: creating the array inside draw()

Creating and filling the array inside draw() produces new random positions every frame. The circles jitter because each call replaces the positions that the previous frame used.

function draw() {
  background(100);

  let ys = new Array();

  for (let i = 0; i < 20; i++) {
    ys[i] = random(80, 120);
  }

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    circle(x, ys[i], 20);
  }
}
Explore more parameters

Animate while Retaining Differences

Now combine the two sources: randomness sampled once in setup(), and time read in draw(). Each circle keeps its own stored height while its size pulses with millis().

function draw() {
  background(100);

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    let size = 15 + 5 * sin(i + millis() / 100);
    circle(x, ys[i], size);
  }
}
Explore more parameters

The retained values make the circles different from one another; the time input makes them move. Declaring ys outside the functions is what lets setup() and draw() share it, but scope alone never changes a value. The sizes move because draw() recomputes them from millis() each frame; the next section moves the stored values themselves. A variable only animates when the sketch updates it or reads an input that changes.

The setup code this sketch shares

The live sketch above hides this fragment for brevity; its Copy button includes it, so the copied sketch is complete and runnable.

let ys = new Array();

function setup() {
  createCanvas(500, 200);
  for (let i = 0; i < 20; i++) {
    ys[i] = random(80, 120);
  }
}

Elapsed Time versus Per-Frame Increments

There are two ways to make a value change over time: compute it from elapsed time with millis(), or update it by a fixed amount on each frame. The two agree only while the frame rate matches what the author imagined.

Frame 0 · 0.00 simulated s
Elapsed time x = 120 px/s × elapsed seconds
120 px/s
Fixed increment x += 2 on each drawn frame
60 px/s at 30 fps

Both marks are meant to move 120 pixels per second, starting at x = 20 and wrapping after they leave the canvas. The frame-rate control sets a simulated (requested) rate, not a measurement of this browser; the clock counts simulated seconds at that rate. Lower it: the elapsed-time mark keeps 120 pixels per second, while the fixed-increment mark slows down, because it takes the same 2-pixel step fewer times per second. Playback stops after 10 simulated seconds, then Play becomes Replay.

p5.js code: Elapsed time
function setup() {
  createCanvas(500, 200);
}

function draw() {
  background(100);

  let seconds = millis() / 1000;
  let x = -20 + ((120 * seconds + 40) % (width + 40));
  circle(x, height / 2, 20);
}
p5.js code: Fixed increment
let x = 20;

function setup() {
  createCanvas(500, 200);
}

function draw() {
  background(100);

  circle(x, height / 2, 20);
  x += 2;
  if (x > width + 20) {
    x -= width + 40;
  }
}
p5.js code: Increment scaled by deltaTime

To keep the incremental structure but make the speed independent of the frame rate, scale the step by deltaTime, the milliseconds since the previous frame. 0.12 pixels per millisecond times deltaTime gives 120 pixels per second at any frame rate.

let x = 20;

function setup() {
  createCanvas(500, 200);
}

function draw() {
  background(100);

  circle(x, height / 2, 20);
  x += 0.12 * deltaTime;
  if (x > width + 20) {
    x -= width + 40;
  }
}

Per-frame increments are still the right tool when the next position should build on the last one, as in a random walk. Here each stored height drifts downward by a small random amount every frame; when a circle leaves the canvas, it starts again at the top.

let ys = new Array();

function setup() {
  createCanvas(windowWidth, windowHeight);
  for (let i = 0; i < 20; i++) {
    ys[i] = random(80, 120);
  }
}

function draw() {
  background(100);

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    circle(x, ys[i], 20);
    ys[i] += random(1);
    if (ys[i] > height + 10) {
      ys[i] = 0;
    }
  }
}
Explore more parameters

At 60 frames per second, ys[i] += random(1) averages 30 pixels per second. If the browser draws fewer frames per second, the drift slows by the same proportion, because the step happens once per drawn frame. To state a speed in pixels per second, scale the step by elapsed time: ys[i] += 0.03 * deltaTime moves 30 pixels per second at any frame rate, because deltaTime is the milliseconds the previous frame took.

Sample Noise over Time

noise() offers a middle ground between random() and stored values. Nearby inputs produce nearby outputs, and calling it again with the same input returns the same value within the same field. (A new run of the sketch may use a new field.) Sampling the field along the row gives heights that differ from circle to circle yet relate to their neighbors, with no array and no flicker.

function draw() {
  background(100);

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    let y = map(noise(i * 0.3), 0, 1, 80, 120);
    circle(x, y, 20);
  }
}
Explore more parameters

Add time as a second input and the sample point drifts through the field. Because millis() / 1000 changes little between frames, consecutive frames sample nearby points, so the motion is continuous.

function draw() {
  background(100);

  for (let i = 0; i < 20; i++) {
    let x = map(i, 0, 19, 20, width - 20);
    let y = map(noise(i * 0.3, millis() / 1000), 0, 1, 80, 120);
    circle(x, y, 20);
  }
}
Explore more parameters

The input scales set the character of the result. Increasing the 0.3 spaces the samples farther apart in the field, so neighboring circles relate less; increasing the 1000 slows the drift through time. Continuity holds only while the sample inputs stay nearby: a small change in input gives a small change in output, but a large jump in input can jump the output too.

Choose an Approach

If you wantUse
A still drawing that differs from run to runSample random() in setup(); see Fresh versus retained
Differences between objects that persist while animatingStore samples in an array; see Store values in an array
Steady, repeating motionA function of millis(), such as sin(); see Animate with time
Drift, falls, or a random walkUpdate a stored value each frame; see Elapsed versus incremental
A speed that does not depend on the frame rateElapsed time, with millis() or deltaTime; see the timing comparison
Smooth, irregular variation across space or timenoise() with scaled inputs; see Sample noise over time

Every live example on this page has a Copy button that produces a complete sketch. Paste one into the p5.js editor to try the structural edits that number scrubbing cannot reach.


©2020–2022 by Oliver Steele.