AI Gravity Painter - Physics Art Sandbox Paint with gravitational forces! Click to place gravity we

This sketch turns the whole browser window into a glowing particle physics sandbox where clicking places gravitational wells that pull hundreds of neon particles into swirling orbits, and dragging creates repulsion zones that scatter them apart. A lightweight built-in 'AI' analyzes the current flow of particles and suggests new well placements to either balance the composition (symmetry) or intensify the turbulence (chaos), and pressing C freezes the whole system into a crystalline nearest-neighbor graph.

🧪 Try This!

Experiment with the code by making these changes:

  1. Slow everything into a dreamlike drift — Increasing the friction damping factor makes particles lose energy faster, slowing the whole swarm into a lazier, more graceful motion.
  2. Place giant gravity wells — Increasing the click-well radius makes each placed attractor influence a much larger portion of the canvas at once.
  3. Stretch out the glow trails — Lowering the background alpha means old frames fade away much more slowly, leaving long, dreamy light trails behind every particle.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch turns your browser into a glowing gravitational sandbox: hundreds of tiny particles drift and collide, leaving neon trails that stretch and curve around gravity wells and repulsion zones you place with the mouse. What makes it visually striking is the combination of HSB color mapped to particle speed, additive blend modes for a genuine glow effect, and inverse-square-style force falloff borrowed from orbital mechanics. On top of the physics, a lightweight 'AI' feature bins particle positions by angle from the center and suggests new well placements to either balance or destabilize the flow.

The code is organized around two ES6 classes - Particle and Field - that encapsulate motion and force respectively, plus a draw() loop that ties them together every frame. By studying it you'll learn how to build a particle system with p5.Vector, how additive blending and translucent backgrounds create glowing motion trails, how to implement a radial force field with falloff and softening, and how simple data analysis (binning by angle) can drive procedural, generative suggestions.

⚙️ How It Works

  1. On load, setup() creates a full-window canvas, switches to HSB color mode, and spawns about 450 particles at random positions with random tiny velocities.
  2. Every frame, draw() dims the previous frame slightly instead of clearing it (a low-alpha background), which leaves faint motion trails, then switches to additive blending so overlapping trails glow brighter where they cross.
  3. While the simulation is not crystallized, every particle asks every active field (well or repulsor) how much force to apply, updates its velocity and position accordingly, and wraps around the screen edges like Pac-Man.
  4. handleCollisions() checks every pair of nearby particles each frame and spawns brand-new particles when two collide with enough relative speed, letting the population grow organically up to a cap.
  5. Clicking places an attracting gravity well; dragging past a small distance threshold instead creates a repulsion zone whose size depends on how far you dragged.
  6. Pressing 1 or 2 runs analyzeFlow() to bin particles by angle from the canvas center and proposes new well/repulsor placements - symmetry mode targets sparse regions, chaos mode targets fast, dense regions - and ENTER turns those ghost suggestions into real fields, while C freezes particles into a nearest-neighbor crystal graph.

🎓 Concepts You'll Learn

p5.Vector physics (position, velocity, acceleration)ES6 classes for particles and force fieldsHSB color mode & additive blend modesInverse-square force falloff with softeningParticle system collision detectionScreen-edge wrappingProcedural 'AI' suggestions via angular binning

📝 Code Breakdown

Particle (class)

Particle is the core building block of the whole simulation - every dot you see is an instance of this class. Studying it teaches the classic physics-integration pattern (acceleration -> velocity -> position) that underlies almost every particle system in creative coding.

🔬 This block wraps particles around the screen edges like Pac-Man so they never vanish. What do you think happens visually if you comment out all four lines?

    if (this.pos.x < 0) this.pos.x += width;
    if (this.pos.x > width) this.pos.x -= width;
    if (this.pos.y < 0) this.pos.y += height;
    if (this.pos.y > height) this.pos.y -= height;

🔬 Fast particles get hue 320 (magenta) and slow ones get hue 180 (turquoise). What happens if you swap those two hue numbers?

    const hue = lerp(180, 320, norm);
    const bright = lerp(60, 100, norm);
    const alpha = lerp(30, 90, norm);
class Particle {
  constructor(x, y) {
    this.pos = createVector(x, y);
    this.prevPos = this.pos.copy();
    this.vel = p5.Vector.random2D().mult(random(0.3, 2.0));
    this.acc = createVector(0, 0);
    this.size = random(1.0, 2.5);
  }

  applyField(field) {
    field.applyTo(this);
  }

  update() {
    this.prevPos.set(this.pos);

    this.vel.add(this.acc);
    // friction/damping
    this.vel.mult(0.99);

    // clamp max speed
    const maxSpeed = 8;
    if (this.vel.magSq() > maxSpeed * maxSpeed) {
      this.vel.setMag(maxSpeed);
    }

    this.pos.add(this.vel);
    this.acc.mult(0);

    // screen wrap
    if (this.pos.x < 0) this.pos.x += width;
    if (this.pos.x > width) this.pos.x -= width;
    if (this.pos.y < 0) this.pos.y += height;
    if (this.pos.y > height) this.pos.y -= height;
  }

  draw() {
    const speed = this.vel.mag();
    const norm = constrain(speed / 8, 0, 1);

    // Velocity → color gradient (turquoise → magenta)
    const hue = lerp(180, 320, norm);
    const bright = lerp(60, 100, norm);
    const alpha = lerp(30, 90, norm);

    stroke(hue, 100, bright, alpha);
    strokeWeight(this.size);
    line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y);

    // subtle glow dot
    noStroke();
    fill(hue, 100, bright, 30);
    ellipse(this.pos.x, this.pos.y, this.size * 3, this.size * 3);
  }
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

calculation Random Starting Velocity this.vel = p5.Vector.random2D().mult(random(0.3, 2.0));

Gives every new particle a random direction and speed so the swarm starts with organic motion

conditional Screen Wrap if (this.pos.x < 0) this.pos.x += width;

Teleports a particle to the opposite edge when it exits the canvas, so nothing is ever lost

calculation Velocity-to-Color Mapping const hue = lerp(180, 320, norm);

Converts each particle's current speed into a hue so fast particles glow magenta and slow ones glow turquoise

this.pos = createVector(x, y);
Stores the particle's current position as a p5.Vector so x/y can be updated together
this.vel = p5.Vector.random2D().mult(random(0.3, 2.0));
Picks a random direction (random2D) and scales it to a random small speed to kick off motion
this.vel.mult(0.99);
Multiplies velocity by 0.99 every frame - a tiny friction that slowly bleeds energy so the system doesn't spin out of control
if (this.vel.magSq() > maxSpeed * maxSpeed) {
Checks squared magnitude (faster than sqrt) against a squared speed limit to detect if the particle is moving too fast
this.pos.add(this.vel);
Moves the particle by adding its velocity vector to its position - the core of the animation
this.acc.mult(0);
Resets acceleration to zero after it's been applied, since forces should only affect this one frame
if (this.pos.x < 0) this.pos.x += width;
If the particle drifts off the left edge, it reappears on the right edge instead of disappearing
line(this.prevPos.x, this.prevPos.y, this.pos.x, this.pos.y);
Draws a short line from the previous position to the current one, which is what creates the streaking trail look

Field (class)

Field is the 'physics engine' behind every gravity well and repulsion zone. It shows how to implement a radial force with distance falloff and softening - a pattern used in everything from n-body simulations to fluid-like visual effects.

🔬 This calculates an inverse-square-style force. What happens visually if you remove the division by distSq entirely, so distance no longer weakens the pull?

    let forceMag = (this.strength * falloff) / distSq;
    if (this.isRepulsor) forceMag *= -1;
class Field {
  // strength > 0, isRepulsor toggles push/pull
  constructor(x, y, strength, radius, isRepulsor = false) {
    this.pos = createVector(x, y);
    this.strength = strength;
    this.radius = radius;
    this.isRepulsor = isRepulsor;
  }

  applyTo(p) {
    const dx = this.pos.x - p.pos.x;
    const dy = this.pos.y - p.pos.y;
    let distSq = dx * dx + dy * dy;

    const radiusSq = this.radius * this.radius;
    if (distSq > radiusSq) return; // outside influence

    // Soften near center to avoid crazy accelerations
    const softening = 100;
    distSq += softening;

    let dist = sqrt(distSq);
    if (dist === 0) return;

    let dir = createVector(dx, dy);
    dir.div(dist); // normalize

    // Strength falls off from center to edge
    let falloff = 1 - distSq / (radiusSq + softening);
    falloff = constrain(falloff, 0, 1);

    let forceMag = (this.strength * falloff) / distSq;
    if (this.isRepulsor) forceMag *= -1;

    dir.mult(forceMag);
    p.acc.add(dir);
  }

  draw() {
    const alpha = 40;
    noFill();
    strokeWeight(1.5);

    if (this.isRepulsor) {
      // repulsor: magenta
      stroke(320, 100, 100, alpha);
    } else {
      // attractor: cyan
      stroke(190, 100, 100, alpha);
    }

    ellipse(this.pos.x, this.pos.y, this.radius * 2, this.radius * 2);

    // core
    if (this.isRepulsor) {
      fill(320, 100, 100, 80);
    } else {
      fill(190, 100, 100, 80);
    }
    noStroke();
    ellipse(this.pos.x, this.pos.y, 8, 8);
  }
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Outside Influence Check if (distSq > radiusSq) return; // outside influence

Skips particles that are too far away from this field, saving computation and preventing force from acting outside its radius

calculation Force Falloff let falloff = 1 - distSq / (radiusSq + softening);

Makes the force weaken smoothly from the center of the field toward its edge instead of cutting off abruptly

conditional Repulsor Flip if (this.isRepulsor) forceMag *= -1;

Reverses the force direction so repulsion zones push particles away instead of pulling them in

const dx = this.pos.x - p.pos.x;
Finds how far the field is from the particle horizontally, pointing toward the field
if (distSq > radiusSq) return; // outside influence
Early-exits for particles outside this field's radius, so distant particles aren't affected at all
const softening = 100;
Adds a fixed amount to the squared distance so forces near the very center don't explode toward infinity
dir.div(dist); // normalize
Turns the direction vector into a unit vector (length 1) so only forceMag controls its strength
let forceMag = (this.strength * falloff) / distSq;
Combines the field's base strength, the edge falloff, and an inverse-square drop-off with distance - just like real gravity
if (this.isRepulsor) forceMag *= -1;
Flips the sign of the force for repulsion zones so particles are pushed away instead of pulled in
p.acc.add(dir);
Adds this field's contribution onto the particle's acceleration; multiple fields can stack their effects on the same particle

setup()

setup() runs exactly once when the page loads. It's the right place to configure the canvas, pick a color system, and populate any starting data like the initial particle array.

function setup() {
  createCanvas(windowWidth, windowHeight); // https://p5js.org/reference/#/p5/createCanvas
  colorMode(HSB, 360, 100, 100, 100);
  background(0);
  initParticles(numInitialParticles);
}
Line-by-line explanation (4 lines)
createCanvas(windowWidth, windowHeight);
Creates a canvas that fills the entire browser window so the sandbox uses all available screen space
colorMode(HSB, 360, 100, 100, 100);
Switches p5's color system to Hue-Saturation-Brightness with 0-360 hue range, making it easy to shift colors smoothly by animating a single hue number
background(0);
Fills the canvas with solid black once before drawing starts
initParticles(numInitialParticles);
Calls the helper function to fill the particles array with the starting swarm of dots

draw()

draw() is the animation heartbeat of the sketch, running ~60 times per second. It shows how to structure a frame around a mode switch (fluid vs crystallized), physics updates, collision handling, and layered drawing with different blend modes.

🔬 Each particle checks every field before it moves. What happens if you comment out the p.applyField(f); call - do particles still drift, and why?

  if (!crystallized) {
    // Apply fields + update particles
    for (let p of particles) {
      for (let f of fields) {
        p.applyField(f);
      }
      p.update();
    }
function draw() {
  // Fading dark background for trails
  background(0, 0, 0, 18);

  // Glowy additive blending for particles & fields
  blendMode(ADD);

  if (!crystallized) {
    // Apply fields + update particles
    for (let p of particles) {
      for (let f of fields) {
        p.applyField(f);
      }
      p.update();
    }

    // Collisions that spawn new particles
    handleCollisions();

    // Draw particles (neon trails)
    for (let p of particles) {
      p.draw();
    }

    // Draw fields on top of trails
    for (let f of fields) {
      f.draw();
    }
  } else {
    // Crystallized mode: draw geometric structure
    drawCrystals();
  }

  // Draw AI suggestions (ghost wells/zones)
  drawSuggestions();

  // Back to normal blending for UI text
  blendMode(BLEND);
  drawHUD();
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Fluid vs Crystallized Branch if (!crystallized) {

Switches every frame between running the live particle simulation and drawing the frozen crystal geometry

for-loop Apply Fields To Particles for (let p of particles) {

Loops through every particle and every field so all forces are applied before anything moves

for-loop Draw Particles for (let p of particles) {

Draws every particle's trail line and glow dot after physics has been updated

background(0, 0, 0, 18);
Draws a nearly-transparent black rectangle over everything instead of fully clearing the frame, which is what creates the fading motion-trail effect
blendMode(ADD);
Switches to additive color blending so overlapping trails and glow dots brighten each other instead of just covering one another
if (!crystallized) {
Only runs the live physics/update code when the simulation isn't frozen into crystal mode
handleCollisions();
Checks every particle pair for close encounters and spawns new particles from fast collisions
drawCrystals();
When crystallized, draws the static nearest-neighbor line graph instead of moving particles
drawSuggestions();
Draws ghost circles for any pending AI-generated well/repulsor suggestions, in either mode
blendMode(BLEND);
Restores normal blending before drawing the HUD text so it stays crisp and readable instead of glowing

windowResized()

windowResized() is a special p5.js callback that fires automatically whenever the browser window is resized, letting your sketch stay full-screen responsive.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  background(0);
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Automatically called by p5 when the browser window changes size; this resizes the canvas to match the new dimensions
background(0);
Repaints the canvas black after resizing so there's no leftover garbage pixels from the old canvas size

initParticles()

This helper function keeps particle creation in one place, so both setup() and the 'R' reset key can rebuild the swarm with a single function call.

function initParticles(count) {
  particles = [];
  for (let i = 0; i < count; i++) {
    particles.push(new Particle(random(width), random(height)));
  }
  // Keep existing fields
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

for-loop Spawn Particles Loop for (let i = 0; i < count; i++) {

Creates 'count' new Particle objects at random positions and adds them to the particles array

particles = [];
Empties the particles array completely before repopulating it, so old particles don't linger
particles.push(new Particle(random(width), random(height)));
Creates a brand new Particle at a random x/y position anywhere on the canvas and adds it to the array

handleCollisions()

This function implements brute-force pairwise collision detection, an O(n²) approach that's simple to write but gets slow with many particles - a great example for learning about algorithmic complexity in creative coding.

🔬 New particles are only born when colliding particles are moving fast relative to each other. What happens if you raise relSpeed > 1.5 to relSpeed > 5 instead?

        if (relSpeed > 1.5 && particles.length < maxParticles) {
          // Spawn a new particle from this collision
          const midX = (p1.pos.x + p2.pos.x) * 0.5;
          const midY = (p1.pos.y + p2.pos.y) * 0.5;
function handleCollisions() {
  const n = particles.length;
  if (n > maxParticles) return;

  const collisionRadiusSq = 9; // 3px radius

  for (let i = 0; i < n; i++) {
    const p1 = particles[i];
    for (let j = i + 1; j < n; j++) {
      const p2 = particles[j];

      const dx = p1.pos.x - p2.pos.x;
      const dy = p1.pos.y - p2.pos.y;
      const distSq = dx * dx + dy * dy;

      if (distSq < collisionRadiusSq) {
        const relSpeed = p5.Vector.sub(p1.vel, p2.vel).mag();
        if (relSpeed > 1.5 && particles.length < maxParticles) {
          // Spawn a new particle from this collision
          const midX = (p1.pos.x + p2.pos.x) * 0.5;
          const midY = (p1.pos.y + p2.pos.y) * 0.5;

          const newborn = new Particle(midX, midY);
          newborn.vel = p5.Vector.add(p1.vel, p2.vel)
            .mult(0.5)
            .rotate(random(-0.5, 0.5));

          newborn.size = (p1.size + p2.size) * 0.45;
          particles.push(newborn);
        }

        // Gentle separating impulse so they don't stick
        const normal = createVector(dx, dy).normalize().mult(0.05);
        p1.vel.add(normal);
        p2.vel.sub(normal);
      }
    }
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Pairwise Collision Check for (let j = i + 1; j < n; j++) {

Compares every unique pair of particles (never the same pair twice) to see if they're close enough to collide

conditional Spawn New Particle if (relSpeed > 1.5 && particles.length < maxParticles) {

Only creates a new particle when two colliding particles are moving fast relative to each other, and the population cap hasn't been reached

if (n > maxParticles) return;
Skips all collision checking entirely once the particle count is already over the cap, as a performance safeguard
const distSq = dx * dx + dy * dy;
Uses squared distance to avoid an expensive sqrt() call when just comparing against a fixed collision radius
const relSpeed = p5.Vector.sub(p1.vel, p2.vel).mag();
Calculates how fast the two particles are moving relative to each other - a slow graze shouldn't spawn a new particle
const newborn = new Particle(midX, midY);
Creates a brand-new particle exactly between the two colliding particles
newborn.vel = p5.Vector.add(p1.vel, p2.vel) .mult(0.5) .rotate(random(-0.5, 0.5));
Gives the new particle a velocity that's the average of its two parents, then nudges the angle randomly for variety
const normal = createVector(dx, dy).normalize().mult(0.05);
Creates a tiny push-apart force so colliding particles don't stack directly on top of each other forever

toggleCrystallize()

This is a simple mode-toggle function that shows how a single boolean flag (crystallized) can switch the entire draw() loop between two completely different visual behaviors.

function toggleCrystallize() {
  crystallized = !crystallized;
  if (crystallized) {
    buildCrystalGeometry();
  } else {
    crystalLines = [];
    background(0);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

conditional Enter/Exit Crystal Mode if (crystallized) {

Decides whether to build the frozen crystal graph or clear it and return to fluid simulation

crystallized = !crystallized;
Flips the boolean flag between true and false each time the C key is pressed
buildCrystalGeometry();
When entering crystal mode, computes the nearest-neighbor line connections once, using the particles' current frozen positions
crystalLines = [];
When leaving crystal mode, clears out the stored line data since it's no longer needed

buildCrystalGeometry()

This function performs a classic k-nearest-neighbors calculation, a technique used everywhere from data visualization to machine learning, here repurposed to turn a particle swarm into a crystal-like graph.

🔬 This condition prevents duplicate lines by only storing a connection once. What happens if you remove the if-check and always push the line?

      if (i < neighborIndex) {
        crystalLines.push({
          x1: p.pos.x,
          y1: p.pos.y,
          x2: q.pos.x,
          y2: q.pos.y
        });
      }
function buildCrystalGeometry() {
  crystalLines = [];
  if (particles.length < 2) return;

  // Connect each particle to its 2 nearest neighbors
  const k = 2;
  for (let i = 0; i < particles.length; i++) {
    const p = particles[i];
    let nearest = [];

    for (let j = 0; j < particles.length; j++) {
      if (i === j) continue;
      const q = particles[j];
      const dx = p.pos.x - q.pos.x;
      const dy = p.pos.y - q.pos.y;
      const d = dx * dx + dy * dy;
      nearest.push({ j, d });
    }

    nearest.sort((a, b) => a.d - b.d);
    const limit = min(k, nearest.length);

    for (let n = 0; n < limit; n++) {
      const neighborIndex = nearest[n].j;
      const q = particles[neighborIndex];
      // Store unique pairs (i < j) to avoid duplicates
      if (i < neighborIndex) {
        crystalLines.push({
          x1: p.pos.x,
          y1: p.pos.y,
          x2: q.pos.x,
          y2: q.pos.y
        });
      }
    }
  }
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Measure Distance To Every Other Particle for (let j = 0; j < particles.length; j++) {

Computes the distance from one particle to every other particle so they can be ranked by closeness

calculation Sort By Nearest nearest.sort((a, b) => a.d - b.d);

Orders all other particles from closest to farthest so the nearest ones can be picked

if (particles.length < 2) return;
Bails out immediately if there aren't at least two particles to connect
const k = 2;
Sets how many nearest neighbors each particle will try to connect to
nearest.push({ j, d });
Records both the index and squared distance of every other particle for later sorting
const limit = min(k, nearest.length);
Prevents trying to connect to more neighbors than actually exist
if (i < neighborIndex) {
Only stores a line when the current index is smaller than the neighbor's, which prevents the same connection being added twice

drawCrystals()

drawCrystals() only runs while crystallized is true, replacing the moving particle simulation with a static, glowing wireframe built from the buildCrystalGeometry() data.

function drawCrystals() {
  // faint background so crystals glow
  background(0, 0, 0, 10);

  // flicker color slightly over time
  const t = frameCount * 0.02;
  const baseHue = (200 + 40 * sin(t)) % 360;

  strokeWeight(1.4);
  noFill();

  for (let lineSeg of crystalLines) {
    const hue = (baseHue + random(-6, 6)) % 360;
    stroke(hue, 80, 100, 80);
    line(lineSeg.x1, lineSeg.y1, lineSeg.x2, lineSeg.y2);

    // small crystalline nodes
    const mx = (lineSeg.x1 + lineSeg.x2) * 0.5;
    const my = (lineSeg.y1 + lineSeg.y2) * 0.5;
    noStroke();
    fill(hue, 90, 100, 60);
    ellipse(mx, my, 3, 3);
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Hue Flicker Over Time const baseHue = (200 + 40 * sin(t)) % 360;

Slowly oscillates the base color of the crystal lines using a sine wave tied to frameCount

for-loop Draw Each Crystal Line for (let lineSeg of crystalLines) {

Draws every stored nearest-neighbor connection as a glowing line with a small node at its midpoint

const t = frameCount * 0.02;
Turns the frame counter into a slowly increasing time value used to drive the sine wave
const baseHue = (200 + 40 * sin(t)) % 360;
Oscillates the base hue between 160 and 240 over time, creating a subtle color shimmer
const hue = (baseHue + random(-6, 6)) % 360;
Adds a small random jitter to each individual line's hue so the crystal doesn't look flat and uniform
ellipse(mx, my, 3, 3);
Draws a tiny dot at the midpoint of each line to look like a crystalline node or joint

analyzeFlow()

analyzeFlow() is the 'sensing' step of the sketch's AI feature. It converts raw particle positions into a compact statistical summary (8 angular bins with average distance and speed) that the suggestion functions use to decide where to place new fields.

function analyzeFlow() {
  const cx = width * 0.5;
  const cy = height * 0.5;

  let bins = [];
  for (let i = 0; i < NUM_BINS; i++) {
    bins.push({
      count: 0,
      distSum: 0,
      speedSum: 0
    });
  }

  for (let p of particles) {
    const dx = p.pos.x - cx;
    const dy = p.pos.y - cy;
    let angle = atan2(dy, dx); // -PI..PI
    if (angle < 0) angle += TWO_PI;

    let idx = floor(map(angle, 0, TWO_PI, 0, NUM_BINS));
    if (idx >= NUM_BINS) idx = NUM_BINS - 1;

    const dist = sqrt(dx * dx + dy * dy);
    const speed = p.vel.mag();

    const bin = bins[idx];
    bin.count++;
    bin.distSum += dist;
    bin.speedSum += speed;
  }

  const baseRadius = min(width, height) * 0.3;

  for (let bin of bins) {
    if (bin.count > 0) {
      bin.avgDist = bin.distSum / bin.count;
      bin.avgSpeed = bin.speedSum / bin.count;
    } else {
      bin.avgDist = baseRadius;
      bin.avgSpeed = 0;
    }
  }

  return bins;
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

for-loop Sort Particles Into Angle Bins for (let p of particles) {

Places every particle into one of NUM_BINS angular slices based on its direction from the canvas center

for-loop Average Each Bin for (let bin of bins) {

Converts each bin's running totals into averages so they can be compared fairly regardless of how many particles landed in each bin

let angle = atan2(dy, dx); // -PI..PI
Calculates the angle from the canvas center to this particle using atan2, which returns a value between -PI and PI
if (angle < 0) angle += TWO_PI;
Shifts any negative angle into the 0 to TWO_PI range so it maps cleanly onto bin indices
let idx = floor(map(angle, 0, TWO_PI, 0, NUM_BINS));
Maps the angle into a bin index between 0 and NUM_BINS, then rounds down to get a whole number
bin.distSum += dist;
Accumulates the total distance-from-center for this bin, used later to compute an average
bin.avgSpeed = bin.speedSum / bin.count;
Divides the summed speed by the particle count in that bin to get the average speed for that angular slice

generateSymmetrySuggestions()

This function is the 'balance' half of the AI feature: it looks for the emptiest angular regions around the center and proposes new attracting wells there to even out the particle distribution.

function generateSymmetrySuggestions() {
  if (particles.length === 0) return;

  const bins = analyzeFlow();
  let indices = [...Array(NUM_BINS).keys()];

  // sort by lowest density first (we want to "balance" them)
  indices.sort((a, b) => bins[a].count - bins[b].count);

  const cx = width * 0.5;
  const cy = height * 0.5;
  const numSuggestions = 4;

  suggestions = [];

  for (let i = 0; i < numSuggestions; i++) {
    const idx = indices[i % indices.length];
    const bin = bins[idx];
    const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS);

    let radius = bin.avgDist;
    const minR = min(width, height) * 0.18;
    const maxR = min(width, height) * 0.4;
    if (!isFinite(radius) || radius < minR) radius = minR;
    if (radius > maxR) radius = maxR;

    const pos = p5.Vector.fromAngle(angleCenter).mult(radius).add(cx, cy);

    suggestions.push({
      x: pos.x,
      y: pos.y,
      radius: radius * 0.9,
      isRepulsor: false,
      type: 'symmetry'
    });
  }

  aiMode = 'symmetry';
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

calculation Sort Bins By Density indices.sort((a, b) => bins[a].count - bins[b].count);

Orders the 8 angular bins from emptiest to most crowded so suggestions can target the sparse areas

for-loop Build Suggestions for (let i = 0; i < numSuggestions; i++) {

Generates 4 suggested attractor positions, one for each of the sparsest angular regions

const bins = analyzeFlow();
Runs the flow analysis to get up-to-date density and speed stats for every angular slice
indices.sort((a, b) => bins[a].count - bins[b].count);
Sorts bin indices ascending by particle count, so emptiest regions come first
const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS);
Calculates the middle angle of a bin's angular slice, used to position the suggestion radially
const pos = p5.Vector.fromAngle(angleCenter).mult(radius).add(cx, cy);
Converts an angle and radius into an actual x/y position on the canvas, relative to the center
isRepulsor: false,
Symmetry suggestions are always attractors, meant to gently pull particles into empty regions

generateChaosSuggestions()

This is the 'destabilize' half of the AI feature: it targets the fastest, most crowded regions and proposes a mix of attractors and repulsors there to amplify existing turbulence rather than calm it.

function generateChaosSuggestions() {
  if (particles.length === 0) return;

  const bins = analyzeFlow();
  let indices = [...Array(NUM_BINS).keys()];

  // turbulence score: faster + more particles
  indices.sort(
    (a, b) =>
      bins[b].avgSpeed * bins[b].count - bins[a].avgSpeed * bins[a].count
  );

  const cx = width * 0.5;
  const cy = height * 0.5;
  const numSuggestions = 5;

  suggestions = [];

  for (let i = 0; i < numSuggestions; i++) {
    const idx = indices[i % indices.length];
    const bin = bins[idx];
    const angleCenter = (idx + 0.5) * (TWO_PI / NUM_BINS);

    let radius = bin.avgDist * 0.9;
    const minR = min(width, height) * 0.15;
    const maxR = min(width, height) * 0.45;
    if (!isFinite(radius) || radius < minR) radius = minR;
    if (radius > maxR) radius = maxR;

    const pos = p5.Vector.fromAngle(angleCenter).mult(radius).add(cx, cy);

    const isRep = i % 2 === 0; // alternate repulsor/attractor

    suggestions.push({
      x: pos.x,
      y: pos.y,
      radius: radius * 0.8,
      isRepulsor: isRep,
      type: 'chaos'
    });
  }

  aiMode = 'chaos';
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Sort Bins By Turbulence bins[b].avgSpeed * bins[b].count - bins[a].avgSpeed * bins[a].count

Ranks angular bins by a 'turbulence score' combining how fast and how crowded each region is

conditional Alternate Repulsor/Attractor const isRep = i % 2 === 0; // alternate repulsor/attractor

Makes every other suggestion a repulsor instead of an attractor, to intensify turbulence rather than calm it

bins[b].avgSpeed * bins[b].count - bins[a].avgSpeed * bins[a].count
Sorts descending by 'speed times count' so the busiest, fastest-moving regions of the canvas come first
let radius = bin.avgDist * 0.9;
Slightly shrinks the suggested radius compared to symmetry mode, making chaos zones feel tighter and more intense
const isRep = i % 2 === 0; // alternate repulsor/attractor
Uses the modulo operator to alternate true/false, mixing pushes and pulls to maximize disorder

applySuggestions()

This function bridges the 'preview' and 'commit' steps of the AI feature - suggestions are harmless ghost data until this function turns them into fields that actually influence particle motion.

function applySuggestions() {
  if (suggestions.length === 0) return;

  const baseStrength = 1600;

  for (let s of suggestions) {
    fields.push(
      new Field(s.x, s.y, baseStrength, s.radius, s.isRepulsor)
    );
  }

  suggestions = [];
  aiMode = 'none';
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

for-loop Convert Suggestions To Fields for (let s of suggestions) {

Turns every ghost suggestion into a real, active Field object that will actually affect particles

if (suggestions.length === 0) return;
Does nothing if ENTER is pressed but no AI suggestions currently exist
fields.push( new Field(s.x, s.y, baseStrength, s.radius, s.isRepulsor) );
Creates a permanent Field using the suggestion's saved position, radius, and repulsor flag
suggestions = [];
Clears the ghost suggestions once they've been turned into real fields, so the ghost circles disappear

drawSuggestions()

drawSuggestions() gives the AI feature a visual 'preview' before commitment - the pulsing ghost circles let you see exactly where wells will appear before pressing ENTER.

function drawSuggestions() {
  if (suggestions.length === 0) return;

  const t = frameCount * 0.08;
  const pulse = map(sin(t), -1, 1, 0.7, 1.2);

  noFill();
  strokeWeight(1.4);

  for (let s of suggestions) {
    const r = s.radius * pulse;
    const baseAlpha = 45;

    if (s.type === 'symmetry') {
      stroke(190, 100, 100, baseAlpha);
    } else {
      stroke(320, 100, 100, baseAlpha);
    }

    ellipse(s.x, s.y, r * 2, r * 2);

    // center dot
    noStroke();
    if (s.isRepulsor) {
      fill(320, 100, 100, 80);
    } else {
      fill(190, 100, 100, 80);
    }
    ellipse(s.x, s.y, 6, 6);
  }
}
Line-by-line explanation (3 lines)

🔧 Subcomponents:

calculation Pulsing Animation const pulse = map(sin(t), -1, 1, 0.7, 1.2);

Creates a gentle breathing/pulsing size animation for the ghost circles using a sine wave

for-loop Draw Each Ghost Suggestion for (let s of suggestions) {

Draws a pulsing outline circle and center dot for every pending AI suggestion

if (suggestions.length === 0) return;
Skips all drawing work if there are no active AI suggestions to show
const pulse = map(sin(t), -1, 1, 0.7, 1.2);
Converts a sine wave (-1 to 1) into a scale factor between 0.7 and 1.2 so the circles gently grow and shrink
if (s.type === 'symmetry') {
Colors symmetry suggestions cyan and chaos suggestions magenta, matching the colors used for real fields

drawHUD()

drawHUD() demonstrates building up an array of strings and rendering them all in a loop - a clean pattern for any on-screen debug/status display in p5.js.

function drawHUD() {
  fill(0, 0, 100, 80);
  noStroke();
  textSize(12);
  textAlign(LEFT, TOP);

  let lines = [];

  lines.push('AI GRAVITY PAINTER');
  lines.push(`Particles: ${particles.length.toString().padStart(3, ' ')}`);
  lines.push(`Wells/zones: ${fields.length}`);

  let aiLabel = 'OFF';
  if (aiMode === 'symmetry') aiLabel = 'Symmetry (1)';
  else if (aiMode === 'chaos') aiLabel = 'Chaos (2)';
  lines.push(`AI suggestions: ${aiLabel}`);

  if (crystallized) {
    lines.push('Mode: CRYSTALLIZED (press C to return to fluid)');
  } else {
    lines.push('Mode: Fluid (press C to crystallize geometry)');
  }

  if (showHelp) {
    lines.push('');
    lines.push('Controls:');
    lines.push('  Click: add gravity well (attractor)');
    lines.push('  Drag: create repulsion zone');
    lines.push('  1: AI symmetry suggestions');
    lines.push('  2: AI chaos suggestions');
    lines.push('  ENTER: apply AI suggestions');
    lines.push('  C: crystallize / un-crystallize');
    lines.push('  R: reset particles & fields');
    lines.push('  H: toggle this help');
  } else {
    lines.push('');
    lines.push('Press H for help');
  }

  let y = 10;
  for (let line of lines) {
    text(line, 12, y);
    y += 15;
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional AI Mode Label if (aiMode === 'symmetry') aiLabel = 'Symmetry (1)';

Chooses which text label to show depending on the current AI suggestion mode

conditional Help Text Toggle if (showHelp) {

Shows the full control list or a short reminder, depending on whether help is toggled on

for-loop Render Each HUD Line for (let line of lines) {

Draws every accumulated text line to the screen, stacking them vertically

fill(0, 0, 100, 80);
Sets the HUD text color to near-white (in HSB, 0 hue/0 saturation/100 brightness) with some transparency
lines.push(`Particles: ${particles.length.toString().padStart(3, ' ')}`);
Builds a text string showing the current particle count, padded so the display doesn't jitter as digits change
let y = 10;
Starting vertical position for the first line of text
y += 15;
Moves the next line of text down by 15 pixels, stacking each HUD line below the previous one

mousePressed()

mousePressed() is a built-in p5.js callback fired the instant a mouse button goes down. Here it just records the starting point of a potential click or drag.

function mousePressed() {
  if (crystallized) return; // freeze interaction in crystal mode
  if (mouseButton !== LEFT) return;

  dragStart = createVector(mouseX, mouseY);
  isDragging = true;
}
Line-by-line explanation (3 lines)
if (crystallized) return; // freeze interaction in crystal mode
Ignores clicks entirely while the sketch is in crystallized mode, since the geometry is meant to be static
if (mouseButton !== LEFT) return;
Only responds to the left mouse button, ignoring right/middle clicks
dragStart = createVector(mouseX, mouseY);
Remembers exactly where the mouse was pressed down, needed to measure drag distance later

mouseReleased()

This function shows a common UI pattern: using a distance threshold to distinguish a 'click' gesture from a 'drag' gesture, then branching to completely different behavior for each.

function mouseReleased() {
  if (crystallized) return;
  if (!isDragging || !dragStart) return;

  const dx = mouseX - dragStart.x;
  const dy = mouseY - dragStart.y;
  const dragDist = sqrt(dx * dx + dy * dy);

  const baseStrength = 2000;
  const baseRadius = min(width, height) * 0.2;

  if (dragDist < dragThreshold) {
    // Click → gravity well (attractor)
    const radius = baseRadius;
    fields.push(new Field(mouseX, mouseY, baseStrength, radius, false));
  } else {
    // Drag → repulsion zone, radius = drag distance (clamped)
    const maxR = min(width, height) * 0.45;
    const radius = constrain(dragDist, 40, maxR);
    fields.push(new Field(dragStart.x, dragStart.y, baseStrength, radius, true));
  }

  isDragging = false;
  dragStart = null;
}
Line-by-line explanation (5 lines)

🔧 Subcomponents:

conditional Click vs Drag Decision if (dragDist < dragThreshold) {

Decides whether the mouse gesture counts as a simple click (small movement) or an intentional drag (larger movement)

const dragDist = sqrt(dx * dx + dy * dy);
Calculates the straight-line distance between where the mouse was pressed and where it was released
if (dragDist < dragThreshold) {
If the mouse barely moved, treats the gesture as a click rather than a drag
fields.push(new Field(mouseX, mouseY, baseStrength, radius, false));
Creates a new attracting gravity well (isRepulsor = false) at the click location
const radius = constrain(dragDist, 40, maxR);
Uses the drag distance itself as the repulsion zone's radius, clamped between a minimum of 40px and a maximum size
fields.push(new Field(dragStart.x, dragStart.y, baseStrength, radius, true));
Creates a repulsion zone (isRepulsor = true) anchored at the original drag start point, not the release point

mouseDragged()

mouseDragged() fires repeatedly while the mouse moves with a button held down. In this sketch it's currently mostly a placeholder, since the actual repulsion zone is only created once in mouseReleased().

function mouseDragged() {
  // just track that dragging is happening; preview handled in draw via suggestion style
  if (crystallized) return;
}
Line-by-line explanation (1 lines)
if (crystallized) return;
The only current logic in this callback - prevents any drag-related behavior while frozen in crystal mode

keyPressed()

keyPressed() is a built-in p5.js callback that fires once per key press. This if/else-if chain is a common pattern for dispatching many different keyboard shortcuts from a single function.

🔬 Pressing R wipes everything and rebuilds the particles. What happens if you remove the 'fields = [];' line so pressing R keeps your placed gravity wells?

  } else if (key === 'R' || key === 'r') {
    fields = [];
    suggestions = [];
    crystallized = false;
    crystalLines = [];
    initParticles(numInitialParticles);
    background(0);
  }
function keyPressed() {
  if (key === '1') {
    generateSymmetrySuggestions();
  } else if (key === '2') {
    generateChaosSuggestions();
  } else if (keyCode === ENTER) {
    applySuggestions();
  } else if (key === 'C' || key === 'c') {
    toggleCrystallize();
  } else if (key === 'R' || key === 'r') {
    fields = [];
    suggestions = [];
    crystallized = false;
    crystalLines = [];
    initParticles(numInitialParticles);
    background(0);
  } else if (key === 'H' || key === 'h') {
    showHelp = !showHelp;
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Key Command Dispatch if (key === '1') {

Routes each key press to a different feature: AI suggestions, applying them, crystallizing, resetting, or toggling help

if (key === '1') {
Pressing 1 requests the AI's symmetry suggestions, aimed at balancing the particle distribution
} else if (keyCode === ENTER) {
Pressing Enter commits any pending ghost suggestions into real fields
} else if (key === 'R' || key === 'r') {
Handles both uppercase and lowercase R to fully reset the sketch - clearing fields, suggestions, and rebuilding particles
showHelp = !showHelp;
Toggles the boolean that controls whether the full control list is shown in the HUD

📦 Key Variables

particles array

Holds every active Particle object currently being simulated and drawn

let particles = [];
fields array

Holds every active Field object (gravity wells and repulsion zones) placed by the user or AI

let fields = [];
suggestions array

Holds pending AI-generated ghost suggestions that haven't been committed to real fields yet

let suggestions = [];
numInitialParticles number

How many particles are created when the sketch starts or is reset

let numInitialParticles = 450;
maxParticles number

Upper limit on total particles allowed, preventing the collision system from spawning unlimited new particles

let maxParticles = 900;
isDragging boolean

Tracks whether the user currently has the mouse button held down, used to distinguish clicks from drags

let isDragging = false;
dragStart object

Stores the p5.Vector position where a mouse drag began, used to measure drag distance and anchor repulsion zones

let dragStart = null;
dragThreshold number

Minimum pixel distance the mouse must move for a gesture to count as a drag instead of a click

let dragThreshold = 12;
crystallized boolean

Flags whether the sketch is currently frozen into the static crystal-graph visualization instead of running live physics

let crystallized = false;
crystalLines array

Stores the computed nearest-neighbor line segments used to draw the crystal geometry

let crystalLines = [];
showHelp boolean

Controls whether the full control list is displayed in the HUD or just a short reminder

let showHelp = true;
aiMode string

Tracks which AI suggestion mode is currently active ('none', 'symmetry', or 'chaos') for HUD display and logic

let aiMode = 'none';
NUM_BINS number

Number of angular slices used to analyze particle flow around the canvas center for the AI features

const NUM_BINS = 8;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

PERFORMANCE handleCollisions()

The collision check compares every pair of particles (an O(n²) nested loop), so at the maximum of 900 particles this runs roughly 400,000 distance checks per frame, which can noticeably slow down the frame rate on less powerful devices.

💡 Use a spatial partitioning structure (like a uniform grid keyed by rounded x/y position) so each particle only checks against nearby neighbors instead of every other particle.

FEATURE mouseDragged()

The function is essentially empty, so while the user is actively dragging to create a repulsion zone, there is no live visual preview - the zone only appears after mouseReleased() fires.

💡 Track isDragging and dragStart in draw() to render a temporary ghost circle (similar to drawSuggestions()) from dragStart to the current mouseX/mouseY while dragging.

STYLE Field constructor calls & applySuggestions/mouseReleased

Important tuning values like field strength (2000, 1600), softening (100), and radius multipliers are hard-coded as separate magic numbers in multiple places, making the simulation's balance hard to reason about or adjust consistently.

💡 Extract these into a small config object or named constants near the top of the file (e.g., const WELL_STRENGTH = 2000;) so all tuning happens in one visible location.

FEATURE keyPressed() / fields array

There is no way to remove an individual gravity well or repulsion zone once it's placed - the only option is a full reset with R, which also wipes all particles.

💡 Add a right-click or dedicated key handler that finds and removes the nearest field to the mouse, letting users fine-tune their composition without starting over.

🔄 Code Flow

Code flow showing particle, field, setup, draw, windowresized, initparticles, handlecollisions, togglecrystallize, buildcrystalgeometry, drawcrystals, analyzeflow, generatesymmetrysuggestions, generatechaossuggestions, applysuggestions, drawsuggestions, drawhud, mousepressed, mousereleased, mousedragged, keypressed

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> draw-mode-branch[draw-mode-branch] draw-mode-branch -->|Fluid Mode| draw-apply-fields-loop[draw-apply-fields-loop] draw-apply-fields-loop --> draw-particles-loop[draw-particles-loop] draw-particles-loop --> draw draw-mode-branch -->|Crystallized Mode| drawcrystals[drawcrystals] drawcrystals --> draw draw --> drawhud[drawhud] drawhud --> draw draw-apply-fields-loop --> field-outside-check[field-outside-check] field-outside-check -->|Valid| field-falloff[field-falloff] field-falloff --> particle-color-map[particle-color-map] particle-color-map --> particle-screen-wrap[particle-screen-wrap] particle-screen-wrap --> particle-constructor-vel[particle-constructor-vel] initparticles --> initparticles-loop[initparticles-loop] initparticles-loop --> particle-constructor-vel handlecollisions --> collision-pair-loop[collision-pair-loop] collision-pair-loop --> collision-spawn-if[collision-spawn-if] togglecrystallize --> crystallize-toggle-if[crystallize-toggle-if] buildcrystalgeometry --> crystal-distance-loop[crystal-distance-loop] crystal-distance-loop --> crystal-sort[crystal-sort] crystal-sort --> crystal-flicker-calc[crystal-flicker-calc] crystal-flicker-calc --> crystal-draw-loop[crystal-draw-loop] analyzeflow --> analyze-particle-loop[analyze-particle-loop] analyze-particle-loop --> analyze-average-loop[analyze-average-loop] analyze-average-loop --> symmetry-sort[symmetry-sort] symmetry-sort --> symmetry-loop[symmetry-loop] symmetry-loop --> generatesymmetrysuggestions[generatesymmetrysuggestions] analyze-average-loop --> chaos-sort[chaos-sort] chaos-sort --> generatechaossuggestions[generatechaossuggestions] generatechaossuggestions --> chaos-alternate[chaos-alternate] applysuggestions --> apply-suggestions-loop[apply-suggestions-loop] apply-suggestions-loop --> suggestion-loop[suggestion-loop] suggestion-loop --> suggestion-pulse[suggestion-pulse] mousepressed --> release-click-vs-drag[release-click-vs-drag] mousereleased --> release-click-vs-drag keypressed --> keypressed-chain[keypressed-chain] click setup href "#fn-setup" click draw href "#fn-draw" click draw-mode-branch href "#sub-draw-mode-branch" click draw-apply-fields-loop href "#sub-draw-apply-fields-loop" click draw-particles-loop href "#sub-draw-particles-loop" click field-outside-check href "#sub-field-outside-check" click field-falloff href "#sub-field-falloff" click particle-color-map href "#sub-particle-color-map" click particle-screen-wrap href "#sub-particle-screen-wrap" click particle-constructor-vel href "#sub-particle-constructor-vel" click initparticles-loop href "#sub-initparticles-loop" click collision-pair-loop href "#sub-collision-pair-loop" click collision-spawn-if href "#sub-collision-spawn-if" click crystallize-toggle-if href "#sub-crystallize-toggle-if" click crystal-distance-loop href "#sub-crystal-distance-loop" click crystal-sort href "#sub-crystal-sort" click crystal-flicker-calc href "#sub-crystal-flicker-calc" click crystal-draw-loop href "#sub-crystal-draw-loop" click analyze-particle-loop href "#sub-analyze-particle-loop" click analyze-average-loop href "#sub-analyze-average-loop" click symmetry-sort href "#sub-symmetry-sort" click symmetry-loop href "#sub-symmetry-loop" click generatesymmetrysuggestions href "#fn-generatesymmetrysuggestions" click chaos-sort href "#sub-chaos-sort" click generatechaossuggestions href "#fn-generatechaossuggestions" click chaos-alternate href "#sub-chaos-alternate" click apply-suggestions-loop href "#sub-apply-suggestions-loop" click suggestion-loop href "#sub-suggestion-loop" click suggestion-pulse href "#sub-suggestion-pulse" click release-click-vs-drag href "#sub-release-click-vs-drag" click keypressed-chain href "#sub-keypressed-chain"

❓ Frequently Asked Questions

What visual experience does the AI Gravity Painter sketch create?

The AI Gravity Painter generates a dynamic visual experience where particles are influenced by gravitational forces, creating vibrant trails and patterns based on their interactions.

How can users interact with the AI Gravity Painter sketch?

Users can click to place gravity wells, drag to create repulsion zones, and utilize AI suggestions for symmetry or chaos, enhancing their creative painting experience.

What coding concepts are demonstrated in the AI Gravity Painter sketch?

This sketch showcases concepts such as particle physics, gravitational forces, and interactive simulation, allowing for real-time visual experimentation.

Preview

AI Gravity Painter - Physics Art Sandbox Paint with gravitational forces! Click to place gravity we - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Gravity Painter - Physics Art Sandbox Paint with gravitational forces! Click to place gravity we - Code flow showing particle, field, setup, draw, windowresized, initparticles, handlecollisions, togglecrystallize, buildcrystalgeometry, drawcrystals, analyzeflow, generatesymmetrysuggestions, generatechaossuggestions, applysuggestions, drawsuggestions, drawhud, mousepressed, mousereleased, mousedragged, keypressed
Code Flow Diagram