TooneTouch

TooneTouch fills the screen with a 10x10 grid of circles that glow, pulse, and play musical notes when you click them. Left alone, the piece drifts into an autonomous 'idle mode' where it plays itself, cycling background colors and triggering random cells to a soft ambient drone.

🧪 Try This!

Experiment with the code by making these changes:

  1. Slow down the trail fade — Lowering the alpha value in the interactive-mode fill makes old circle trails linger much longer, creating dreamier smears of light.
  2. Make idle mode trigger cells constantly — Shortening the idle timeout means the sketch takes over and starts playing itself almost immediately after you stop clicking.
  3. Grow circles bigger than their grid cell — Raising the target size multiplier makes triggered circles overlap their neighbors, creating a more chaotic, overlapping look.
Prefer the full editor? Open it there →

📖 About This Sketch

TooneTouch is a generative sound-and-light instrument built with p5.js for visuals and Tone.js for audio. Clicking anywhere on the screen makes the nearest circle in a 10x10 grid swell up, flash brighter, and play a note from a pentatonic scale, while a fading trail effect (achieved by drawing a semi-transparent rectangle over the canvas every frame instead of clearing it) leaves soft afterglow behind every pulse. Ten seconds of inactivity trips the sketch into an idle mode where it triggers random cells itself against a slowly shifting HSB-colored background and a long ambient drone note. The result feels like a living instrument that performs on its own when you stop touching it.

The code is organized around a simple state machine ('interactive' vs 'idle') stored in the mode variable, a Cell class that each grid circle is an instance of, and a handful of Tone.js objects (a PolySynth, a Reverb, and an ambient drone Synth) wired together in setup(). Studying it teaches you how to combine p5.js animation with Web Audio via Tone.js, how to use HSB color mode for easy hue-cycling, how trail effects are faked with translucent background rectangles, and how a lightweight state machine can switch a sketch between user-driven and self-driven behavior.

⚙️ How It Works

  1. When the sketch loads, setup() builds a full-window canvas, switches to HSB color mode, configures the Tone.js synth/reverb/drone, schedules an autonomous Tone.Sequence for idle mode, and lays out a 10x10 grid of Cell objects evenly spaced across the screen.
  2. Every frame, draw() first checks whether the 'Press to Play' overlay is still showing (browsers require a click before audio can start); once dismissed, it paints a semi-transparent rectangle over the whole canvas - black in interactive mode, a slowly hue-shifting color in idle mode - which is what creates the glowing trail effect instead of a hard clear.
  3. Still inside draw(), every Cell in the array is told to update() (grow, shrink, or sit idle) and display() (draw itself as a circle whose saturation and brightness reflect its current size).
  4. Clicking the canvas runs mousePressed(), which finds whichever cell is closest to the mouse using dist() and calls triggerExpand() on it, kicking off an expand-then-contract animation that fires a synth note the instant the circle reaches full size.
  5. If ten seconds pass with no clicks, draw() calls switchMode('idle'), which starts the Tone.Sequence (randomly triggering cells on its own) and begins an ambient drone note; clicking again calls switchMode('interactive'), which stops the sequence and drone and hands control back to the user.
  6. windowResized() keeps the grid centered and correctly spaced whenever the browser window changes size, resetting every cell back to its idle state so the layout doesn't look broken mid-resize.

🎓 Concepts You'll Learn

HSB color mode for easy hue cyclingTone.js synths, effects, and sequencingState machines (idle vs interactive modes)Object-oriented design with a Cell classTrail effects via semi-transparent overlaysEvent-driven audio triggering from animation stateResponsive canvas resizing

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. Here it does double duty: preparing the visual grid AND wiring up the entire Tone.js audio graph (synths -> reverb -> speakers) before any sound can play.

🔬 This nested loop builds the grid. What happens visually and sonically if you change `let note = random(scale);` to always use `scale[0]` instead of a random pick?

  for (let i = 0; i < gridSize; i++) {
    for (let j = 0; j < gridSize; j++) {
      let x = i * cellSpacing + cellSpacing / 2;
      let y = j * cellSpacing + cellSpacing / 2;
      let note = random(scale);
      cells.push(new Cell(x, y, note));
    }
  }
function setup() {
  createCanvas(windowWidth, windowHeight);
  colorMode(HSB, 360, 100, 100, 100); // Use HSB for easier color manipulation
  noStroke(); // No borders for the circles

  // --- Tone.js Setup ---
  // Create a PolySynth with a sine wave oscillator for cell notes
  synth = new Tone.PolySynth(Tone.Synth, {
    oscillator: { type: "sine" },
    envelope: { attack: 0.05, decay: 0.1, sustain: 0.5, release: 1 }
  }).toDestination();

  // Create a Reverb effect for ambience, connected to master output
  reverb = new Tone.Reverb(2).toDestination();
  // Connect the cell synth to the reverb
  synth.connect(reverb);

  // Initialize ambient drone synth
  ambientDrone = new Tone.Synth({
    oscillator: { type: "sawtooth" }, // Sawtooth wave for a richer, more complex drone sound
    envelope: { attack: 4, decay: 2, sustain: 0.8, release: 8 } // Long attack/release for a smooth drone
  }).connect(reverb); // Connect drone to reverb as well

  // Start Tone.Transport for scheduling autonomous events
  // Note: Tone.Transport will start, but audio won't play until Tone.context.resume() is called
  Tone.Transport.start(); // https://tonejs.github.io/docs/14.7.77/Transport#start

  // Initialize idle sequence for autonomous cell triggering
  idleSequence = new Tone.Sequence((time, value) => {
    if (value === 1 && mode === 'idle') {
      triggerRandomCell();
    }
  }, [1, null, 1, null, null, 1, null, 1, null, null, 1, null, 1, null, null, 1], "8n");
  idleSequence.loop = true;
  idleSequence.loopEnd = "2m"; // Loop length of 2 measures

  // --- Visuals Setup ---
  cellSpacing = min(width, height) / gridSize;

  for (let i = 0; i < gridSize; i++) {
    for (let j = 0; j < gridSize; j++) {
      let x = i * cellSpacing + cellSpacing / 2;
      let y = j * cellSpacing + cellSpacing / 2;
      let note = random(scale);
      cells.push(new Cell(x, y, note));
    }
  }

  // We no longer call switchMode('idle') here.
  // The first user interaction will handle starting the sketch.
  lastInteractionTime = millis(); // Initialize interaction time
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

calculation PolySynth + Reverb Setup synth = new Tone.PolySynth(Tone.Synth, { ... }).toDestination();

Creates the synth that plays a note whenever a cell finishes expanding, and routes it through a reverb effect

calculation Idle Sequence Pattern idleSequence = new Tone.Sequence((time, value) => { ... }, [1, null, 1, ...], "8n");

Defines a repeating rhythmic pattern of 1s and nulls that decides which 8th-notes trigger a random cell during idle mode

for-loop Grid Construction for (let i = 0; i < gridSize; i++) { for (let j = 0; j < gridSize; j++) { ... } }

Nested loop that walks through every row and column, calculates each cell's pixel position, assigns it a random note from the scale, and creates a Cell object

createCanvas(windowWidth, windowHeight);
Makes the canvas fill the entire browser window.
colorMode(HSB, 360, 100, 100, 100);
Switches p5's color system to Hue/Saturation/Brightness with ranges 0-360, 0-100, 0-100, 0-100 - much easier for cycling colors smoothly than RGB.
noStroke();
Turns off outlines so every circle drawn later is a clean filled shape with no border.
synth = new Tone.PolySynth(Tone.Synth, {...}).toDestination();
Creates a synth capable of playing several notes at once (poly = many voices) with a sine wave tone, and sends its sound straight to your speakers.
reverb = new Tone.Reverb(2).toDestination();
Creates a reverb effect with a 2-second decay tail and connects it to the speakers, giving sounds a spacious, echoey quality.
synth.connect(reverb);
Routes the cell synth's sound into the reverb before it reaches the speakers, so every note gets that ambient wash.
ambientDrone = new Tone.Synth({...}).connect(reverb);
Creates a second synth with a sawtooth wave and very long attack/release envelope, used only for the sustained background drone in idle mode.
Tone.Transport.start();
Starts Tone.js's internal clock, which is required for scheduled events like the idle Sequence to actually run.
idleSequence = new Tone.Sequence((time, value) => {...}, [...], "8n");
Builds a repeating 16-step pattern (playing every 8th note) where a '1' in the array triggers a random cell, as long as the sketch is currently in idle mode.
cellSpacing = min(width, height) / gridSize;
Divides the smaller of the canvas's width or height by gridSize to figure out how many pixels each grid cell should occupy.
for (let i = 0; i < gridSize; i++) { for (let j = 0; j < gridSize; j++) {...} }
Loops over every row (i) and column (j) to place one Cell at each grid position.
let note = random(scale);
Picks a random note from the pentatonic scale array for this specific cell to play whenever it's triggered.
cells.push(new Cell(x, y, note));
Creates a new Cell object at position (x, y) with its assigned note and adds it to the global cells array.
lastInteractionTime = millis();
Records the current time so the idle-timeout check in draw() has a valid starting point before the user has clicked anything.

draw()

draw() runs continuously at the browser's frame rate. It demonstrates a common p5.js trick - painting a low-alpha rectangle instead of calling background() - to create fading motion trails, plus a simple branching structure that lets one draw loop serve two very different visual states.

🔬 This loop is the heart of the animation. What happens if you call cell.display() before cell.update() instead of after - would the circles look one frame behind?

    for (let cell of cells) {
      cell.update();
      cell.display();
    }
function draw() {
  if (playOverlayActive) {
    // Draw the "Press to Play" overlay
    fill(0); // Black background for the overlay
    rect(0, 0, width, height);

    fill(255); // White text
    textSize(32);
    textAlign(CENTER, CENTER);
    textFont('Arial', 32); // Using a common font and size
    text('Press to Play', width / 2, height / 2);
  } else {
    // Existing drawing logic for idle/interactive mode
    // --- Fading Trails and Background ---
    if (mode === 'interactive') {
      fill(0, 0, 0, 15); // Slightly less transparent black for interactive mode trails
    } else {
      // Idle mode background: pulsing hue, saturation, and brightness for a more vibrant effect
      let idleHue = (frameCount * 0.1) % 360; // Slow, continuous hue shift
      let idleSaturation = map(sin(frameCount * 0.03), -1, 1, 50, 80); // Pulsing saturation
      let idleBrightness = map(sin(frameCount * 0.02), -1, 1, 20, 50); // Pulsing brightness
      fill(idleHue, idleSaturation, idleBrightness, 15); // Semi-transparent color for trails and background
    }
    rect(0, 0, width, height); // Draw over the entire canvas

    // Update and display each cell
    for (let cell of cells) {
      cell.update();
      cell.display();
    }

    // Check for inactivity to switch back to idle mode
    if (mode === 'interactive' && millis() - lastInteractionTime > idleTimeoutDuration) {
      switchMode('idle');
    }
  }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

conditional Press to Play Overlay if (playOverlayActive) { ... }

Shows a black screen with instructional text until the user's first click, since browsers block audio until a user gesture occurs

conditional Trail Color Branch if (mode === 'interactive') { ... } else { ... }

Chooses a flat translucent black in interactive mode, or a slowly shifting HSB color in idle mode, for the trail-fading rectangle

for-loop Update & Display Cells for (let cell of cells) { cell.update(); cell.display(); }

Runs every cell's animation logic and draws it, every single frame

conditional Idle Timeout Check if (mode === 'interactive' && millis() - lastInteractionTime > idleTimeoutDuration) { switchMode('idle'); }

Automatically switches back to idle/self-playing mode after a period of no interaction

if (playOverlayActive) {
Checks whether the introductory overlay is still showing; if true, the rest of draw() is skipped entirely.
fill(0); rect(0, 0, width, height);
Paints the entire canvas solid black as the overlay's backdrop.
text('Press to Play', width / 2, height / 2);
Draws instructional text centered on the screen telling the user to click to begin.
if (mode === 'interactive') { fill(0, 0, 0, 15); }
In interactive mode, sets the fill to nearly-transparent black (alpha 15 out of 100) so old frames fade rather than vanish instantly.
let idleHue = (frameCount * 0.1) % 360;
Calculates a hue that slowly increases every frame and wraps back to 0 after reaching 360, creating a continuous rainbow drift.
let idleSaturation = map(sin(frameCount * 0.03), -1, 1, 50, 80);
Uses a sine wave over time to smoothly oscillate the background's saturation between 50 and 80.
fill(idleHue, idleSaturation, idleBrightness, 15);
Applies the calculated HSB color with low alpha as the semi-transparent overlay color used for idle mode's psychedelic trail effect.
rect(0, 0, width, height);
Draws a full-screen rectangle in the chosen translucent color, which is the trick that fades previous frames into trails instead of a hard clear.
for (let cell of cells) { cell.update(); cell.display(); }
Loops through every Cell object, updating its size/state and then drawing it, every single frame - this is what animates 100 circles simultaneously.
if (mode === 'interactive' && millis() - lastInteractionTime > idleTimeoutDuration) { switchMode('idle'); }
Checks how many milliseconds have passed since the last click; if it exceeds idleTimeoutDuration while in interactive mode, switches the sketch to idle mode.

mousePressed()

mousePressed() is a p5.js event function that fires automatically whenever the mouse button is clicked. Here it juggles two responsibilities: unlocking browser audio on the very first click, and finding+triggering the nearest cell on every click after that.

🔬 This loop always finds the single nearest cell. What do you think would happen if you removed the `if (d < minDist)` check entirely - would every cell trigger, or just the last one checked?

  for (let cell of cells) {
    let d = dist(mouseX, mouseY, cell.x, cell.y);
    if (d < minDist) {
      minDist = d;
      closestCell = cell;
    }
  }
function mousePressed() {
  if (playOverlayActive) {
    // If the overlay is active, this is the first user interaction
    if (Tone.context.state !== 'running') {
      Tone.context.resume(); // Start the audio context
      // https://tonejs.github.io/docs/14.7.77/Context#resume
    }
    playOverlayActive = false; // Dismiss the overlay
    lastInteractionTime = millis(); // Reset interaction timer to prevent immediate idle timeout
    switchMode('idle'); // Start the sketch in idle mode
    return; // Exit mousePressed to prevent immediate cell interaction
  }

  // --- Existing cell interaction logic (only runs after overlay is dismissed) ---
  // Switch to interactive mode on any mouse interaction
  if (mode === 'idle') {
    switchMode('interactive');
  }
  lastInteractionTime = millis(); // Reset interaction timer

  // Find the cell closest to the mouse and trigger its expansion
  let closestCell = null;
  let minDist = Infinity;

  for (let cell of cells) {
    let d = dist(mouseX, mouseY, cell.x, cell.y);
    if (d < minDist) {
      minDist = d;
      closestCell = cell;
    }
  }

  if (closestCell) {
    closestCell.triggerExpand();
  }
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Dismiss Overlay & Start Audio if (playOverlayActive) { ... return; }

Handles the very first click: resumes the browser's audio context (required by autoplay policies), hides the overlay, and starts idle mode

conditional Wake Up From Idle if (mode === 'idle') { switchMode('interactive'); }

Switches from self-playing idle mode to user-controlled interactive mode as soon as a click happens

for-loop Find Closest Cell for (let cell of cells) { let d = dist(mouseX, mouseY, cell.x, cell.y); if (d < minDist) { minDist = d; closestCell = cell; } }

Scans every cell to find whichever one is nearest to the mouse click using the distance formula

if (playOverlayActive) {
Checks whether this is the very first click on the sketch, before any audio has been allowed to play.
if (Tone.context.state !== 'running') { Tone.context.resume(); }
Browsers block audio until a user gesture happens; this line unlocks Tone.js's audio engine the moment the user clicks.
playOverlayActive = false;
Hides the 'Press to Play' screen so draw() starts rendering the actual grid from now on.
switchMode('idle');
Kicks the sketch off in idle (self-playing) mode right after the overlay is dismissed.
return;
Stops the function here so this very first click doesn't also accidentally trigger a random cell.
if (mode === 'idle') { switchMode('interactive'); }
On any later click, if the sketch was idly playing itself, this hands control back to the user.
let d = dist(mouseX, mouseY, cell.x, cell.y);
Calculates the straight-line distance between the mouse position and this cell's center using p5's dist() function.
if (d < minDist) { minDist = d; closestCell = cell; }
Keeps track of whichever cell has been the closest one found so far as the loop checks all 100 cells.
if (closestCell) { closestCell.triggerExpand(); }
Once the nearest cell is found, tells it to start expanding (and eventually play its note).

windowResized()

windowResized() is a p5.js event function that automatically fires whenever the browser window changes size. It's essential for making a full-window sketch like this one stay correctly laid out on any screen.

function windowResized() {
  resizeCanvas(windowWidth, windowHeight);
  cellSpacing = min(width, height) / gridSize;

  // Recalculate cell positions and reset their states and sizes
  let index = 0;
  for (let i = 0; i < gridSize; i++) {
    for (let j = 0; j < gridSize; j++) {
      let x = i * cellSpacing + cellSpacing / 2;
      let y = j * cellSpacing + cellSpacing / 2;
      cells[index].x = x;
      cells[index].y = y;
      cells[index].size = 0; // Reset size
      cells[index].targetSize = 0; // Reset target size
      cells[index].state = 0; // Reset state to idle
      index++;
    }
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

for-loop Reposition Every Cell for (let i = 0; i < gridSize; i++) { for (let j = 0; j < gridSize; j++) { ... index++; } }

Walks through the flat cells array in the same row/column order it was created, recalculating each one's x/y position for the new window size and resetting its animation state

resizeCanvas(windowWidth, windowHeight);
Tells p5.js to resize the actual canvas element to match the browser window's new dimensions.
cellSpacing = min(width, height) / gridSize;
Recomputes the spacing between cells based on the new canvas size, exactly like in setup().
let index = 0;
A counter used to walk through the flat 'cells' array in the same order the nested loop originally filled it.
cells[index].x = x; cells[index].y = y;
Moves this cell to its newly calculated grid position so the layout stays evenly spaced after resizing.
cells[index].size = 0; cells[index].targetSize = 0; cells[index].state = 0;
Resets the cell back to invisible and idle, since an old animation mid-flight wouldn't make sense at the new position.
index++;
Advances to the next cell in the array so the next loop iteration updates a different one.

switchMode()

switchMode() is the heart of this sketch's simple state machine. Centralizing all the side-effects of entering/exiting a mode (starting/stopping audio, resetting cells) in one function keeps the rest of the code clean and predictable.

function switchMode(newMode) {
  mode = newMode;
  if (mode === 'idle') {
    console.log('Switching to Idle Mode');
    idleSequence.start(Tone.now()); // Start the autonomous cell triggering sequence
    // Start the ambient drone in idle mode.
    // We triggerAttackRelease with a very long duration (32 measures)
    // so the drone plays continuously until triggerRelease() is called.
    ambientDrone.triggerAttackRelease("C3", "32m", Tone.now()); // Play a low C note
  } else {
    console.log('Switching to Interactive Mode');
    idleSequence.stop(Tone.now()); // Stop the autonomous sequence
    // Stop the ambient drone in interactive mode by releasing it
    ambientDrone.triggerRelease(Tone.now());
    // Optionally, clear any currently expanding cells to give the user immediate control
    for (let cell of cells) {
      if (cell.state !== 0) {
        cell.size = 0;
        cell.targetSize = 0;
        cell.state = 0;
      }
    }
  }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

conditional Enter Idle Mode if (mode === 'idle') { idleSequence.start(Tone.now()); ambientDrone.triggerAttackRelease("C3", "32m", Tone.now()); }

Starts the autonomous sequence and begins a very long drone note when switching into self-playing mode

conditional Enter Interactive Mode else { idleSequence.stop(Tone.now()); ambientDrone.triggerRelease(Tone.now()); for (let cell of cells) {...} }

Stops the autonomous sequence, releases the drone note, and resets any cells mid-animation when the user takes back control

mode = newMode;
Updates the global mode variable to whichever state was requested ('idle' or 'interactive').
idleSequence.start(Tone.now());
Starts the Tone.Sequence right now, so it begins ticking through its 1/null pattern and triggering random cells.
ambientDrone.triggerAttackRelease("C3", "32m", Tone.now());
Plays a low C note with a duration of 32 measures - effectively 'forever' for this sketch's purposes - creating a sustained ambient drone.
idleSequence.stop(Tone.now());
Immediately halts the autonomous sequence so it stops triggering random cells while the user is interacting.
ambientDrone.triggerRelease(Tone.now());
Releases the drone note early (fading it out via its envelope) instead of waiting for the full 32 measures.
for (let cell of cells) { if (cell.state !== 0) { cell.size = 0; cell.targetSize = 0; cell.state = 0; } }
Loops through all cells and forcibly resets any that were still expanding or contracting, so the user starts interactive mode with a clean, calm grid.

triggerRandomCell()

This function is called from the Tone.Sequence set up in setup(), giving the sketch a way to 'play itself' in idle mode by randomly picking cells at rhythmic intervals.

function triggerRandomCell() {
  let randomCell = random(cells);
  if (randomCell) {
    // Only trigger if the cell is currently idle or contracting
    if (randomCell.state === 0 || randomCell.state === 2) {
      randomCell.triggerExpand();
    }
  }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Check Cell Is Available if (randomCell.state === 0 || randomCell.state === 2) { randomCell.triggerExpand(); }

Only triggers the randomly chosen cell if it isn't already mid-expansion, avoiding interrupting an in-progress animation

let randomCell = random(cells);
p5's random() function, when given an array, returns one random element from it - here, a random Cell object.
if (randomCell) {
A safety check ensuring a cell was actually returned before trying to use it.
if (randomCell.state === 0 || randomCell.state === 2) {
Checks that the chosen cell is either idle (0) or contracting (2) - i.e. not already expanding (1) - before triggering it.
randomCell.triggerExpand();
Starts this cell's expand animation, which will eventually play its assigned note.

Cell() constructor

The constructor runs once when 'new Cell(...)' is called in setup(). It's where you set up all the starting properties an object needs before it does anything else - a foundational pattern in object-oriented JavaScript.

  constructor(x, y, note) {
    this.x = x;
    this.y = y;
    this.size = 0; // Current size of the circle
    this.targetSize = 0; // Desired size for expansion
    this.speed = random(0.02, 0.08); // Speed of expansion/contraction
    this.state = 0; // 0: idle, 1: expanding, 2: contracting
    this.note = note; // The musical note assigned to this cell
    this.hue = random(360); // Random initial hue for the cell
  }
Line-by-line explanation (7 lines)
this.x = x; this.y = y;
Stores this cell's fixed grid position, calculated once in setup() and never changed unless the window resizes.
this.size = 0;
Every cell starts invisible - a circle of size 0 - until it's triggered.
this.targetSize = 0;
The size this cell will grow towards once triggered; also 0 until triggerExpand() sets it.
this.speed = random(0.02, 0.08);
Gives each cell a slightly different randomized growth rate, so not all triggered circles animate in perfect unison.
this.state = 0;
Tracks the cell's current animation phase using a simple number code: 0 = idle, 1 = expanding, 2 = contracting.
this.note = note;
Stores the musical note (like 'C4') this specific cell will play whenever it fully expands.
this.hue = random(360);
Assigns a random starting color hue (0-360 in HSB mode) to this cell.

update()

update() is called every frame for every cell and implements a tiny state machine (idle -> expanding -> contracting -> idle) using easing-like incremental size changes rather than instant jumps, which is what makes the growth and shrink feel smooth.

🔬 This block grows the circle a little every frame. What happens if you multiply the growth amount by a bigger number, like cellSpacing * this.speed * 3 - do circles snap open almost instantly?

    if (this.state === 1) { // If expanding
      if (this.size < this.targetSize) {
        this.size += cellSpacing * this.speed;
        if (this.size >= this.targetSize) {
          this.size = this.targetSize;
  update() {
    if (this.state === 1) { // If expanding
      if (this.size < this.targetSize) {
        this.size += cellSpacing * this.speed;
        if (this.size >= this.targetSize) {
          this.size = this.targetSize;
          // Trigger sound when fully expanded
          synth.triggerAttackRelease(this.note, "4n"); // Play note for a quarter note duration
          // https://tonejs.github.io/docs/14.7.77/PolySynth#triggerattackrelease
          this.state = 2; // Change state to contracting
        }
      }
    } else if (this.state === 2) { // If contracting
      if (this.size > 0) {
        this.size -= cellSpacing * this.speed * 0.5; // Contract slower than expanding
        if (this.size <= 0) {
          this.size = 0;
          this.state = 0; // Change state to idle
        }
      }
    }
  }
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Expanding State if (this.state === 1) { ... }

Grows the circle towards its target size and, once it arrives, plays the assigned note and flips into the contracting state

conditional Contracting State else if (this.state === 2) { ... }

Shrinks the circle back down to zero at half the expansion speed, then returns to the idle state

if (this.state === 1) {
Only runs this block if the cell is currently in the 'expanding' phase.
this.size += cellSpacing * this.speed;
Grows the circle's size a little every frame, scaled by the grid spacing and this cell's individual speed.
synth.triggerAttackRelease(this.note, "4n");
Plays this cell's musical note through the PolySynth for the duration of a quarter note, the moment the circle reaches full size.
this.state = 2;
Switches the cell into the 'contracting' phase so the next update() call shrinks it back down.
else if (this.state === 2) {
Only runs this block if the cell is currently shrinking back down.
this.size -= cellSpacing * this.speed * 0.5;
Shrinks the circle each frame, but at half the rate it expanded - the '* 0.5' makes the contraction feel like a gentle release rather than a snap.
this.state = 0;
Once fully shrunk, returns the cell to the idle state, ready to be triggered again.

display()

display() shows a classic p5.js pattern: using map() to translate one changing value (size) into other visual properties (color) so a single animated variable drives multiple aspects of the look.

🔬 These two map() calls turn size into color intensity. What happens if you swap the output ranges so brightness goes from 100 down to 10 instead - would big circles get darker instead of brighter?

    let saturation = map(this.size, 0, cellSpacing, 30, 100);
    let brightness = map(this.size, 0, cellSpacing, 10, 100);
  display() {
    // Map the cell's size to saturation and brightness for visual feedback
    let saturation = map(this.size, 0, cellSpacing, 30, 100);
    let brightness = map(this.size, 0, cellSpacing, 10, 100);
    fill(this.hue, saturation, brightness, 90); // Use the random hue with mapped saturation/brightness
    circle(this.x, this.y, this.size); // Draw the circle
    // https://p5js.org/reference/#/p5/circle
  }
Line-by-line explanation (4 lines)
let saturation = map(this.size, 0, cellSpacing, 30, 100);
Uses p5's map() to convert the circle's current size (0 to cellSpacing) into a saturation value between 30 and 100 - bigger circles look more vividly colored.
let brightness = map(this.size, 0, cellSpacing, 10, 100);
Similarly maps size to brightness, so a small idle circle looks dim while a fully expanded one glows brightly.
fill(this.hue, saturation, brightness, 90);
Sets the fill color using this cell's hue plus the size-based saturation/brightness, with an alpha of 90 out of 100 (mostly opaque).
circle(this.x, this.y, this.size);
Draws a circle centered at this cell's position with a diameter equal to its current size - when size is 0, nothing visible is drawn.

triggerExpand()

triggerExpand() is the single entry point that both mousePressed() and triggerRandomCell() call to 'activate' a cell, showing how a guard condition (checking state first) keeps an object's animation logic safe from being called at the wrong time.

  triggerExpand() {
    // Only trigger if the cell is idle or contracting
    if (this.state === 0 || this.state === 2) {
      this.targetSize = cellSpacing * 0.9; // Expand to 90% of the cell spacing
      this.state = 1; // Set state to expanding
      this.hue = random(360); // Change hue on trigger for visual variety
      // https://p5js.org/reference/#/p5/random
    }
  }
Line-by-line explanation (4 lines)

🔧 Subcomponents:

conditional Availability Guard if (this.state === 0 || this.state === 2) { ... }

Prevents re-triggering a cell that's already mid-expansion, avoiding jarring size jumps

if (this.state === 0 || this.state === 2) {
Only allows a new trigger if the cell is currently idle or contracting - not while it's already expanding, which would look glitchy.
this.targetSize = cellSpacing * 0.9;
Sets the size this cell will grow towards - 90% of the space it has in the grid, leaving a small gap between neighbors.
this.state = 1;
Flips the cell into the 'expanding' state so update() starts growing it on the next frame.
this.hue = random(360);
Picks a brand new random hue every time the cell is triggered, so repeated touches produce varied colors instead of the same one each time.

📦 Key Variables

cells array

Holds every Cell object in the 10x10 grid; looped over every frame to update and draw the whole scene.

let cells = [];
gridSize number

How many rows and columns of cells make up the grid (10x10 by default).

let gridSize = 10;
cellSpacing number

The pixel distance between grid cells, recalculated whenever the canvas size changes so the grid always fits the window.

let cellSpacing;
synth object

The Tone.js PolySynth used to play a note whenever a cell finishes expanding.

let synth;
reverb object

The Tone.js Reverb effect both the cell synth and ambient drone are routed through for atmosphere.

let reverb;
ambientDrone object

A separate Tone.js Synth used only to hold a long sustained background note while the sketch is in idle mode.

let ambientDrone;
scale array

The pentatonic set of notes ('C4','D4','E4','G4','A4') each cell randomly picks from when created.

const scale = ["C4", "D4", "E4", "G4", "A4"];
mode string

Tracks whether the sketch is currently in 'interactive' or 'idle' state, driving both visuals and audio behavior.

let mode = 'idle';
lastInteractionTime number

Stores the millis() timestamp of the last user click, used to detect inactivity.

let lastInteractionTime = 0;
idleTimeoutDuration number

How many milliseconds of inactivity must pass before the sketch automatically switches to idle mode.

let idleTimeoutDuration = 10000;
idleSequence object

A Tone.Sequence that rhythmically triggers random cells to make the sketch 'play itself' during idle mode.

let idleSequence;
playOverlayActive boolean

Controls whether the 'Press to Play' overlay is shown, since browsers require a user gesture before audio can start.

let playOverlayActive = true;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

PERFORMANCE draw()

The map() and sin() calculations for idleHue/idleSaturation/idleBrightness run every single frame even when not needed elsewhere, and the whole cells array is looped twice conceptually (once for update, once for display) even though it's actually one combined loop - fine at 100 cells, but wouldn't scale well to a much larger grid.

💡 If you increase gridSize significantly, consider caching cell references in a typed structure or splitting update/display loops so each stays simple and easier to profile.

BUG windowResized()

The nested loop assumes cells.length always equals gridSize*gridSize, but if gridSize is changed at runtime (e.g. via a tunable) without rebuilding the cells array, this function will throw an error trying to access cells[index] out of bounds.

💡 Guard the loop with a check like 'if (index < cells.length)' or rebuild the cells array whenever gridSize changes instead of assuming a fixed count.

STYLE Cell.update()

The magic numbers 0, 1, 2 for cell state (idle/expanding/contracting) make the code harder to read at a glance.

💡 Define named constants like const IDLE = 0, EXPANDING = 1, CONTRACTING = 2; and use those names throughout instead of raw numbers.

FEATURE mousePressed()

Only mouse clicks are supported, so the sketch doesn't respond to touch dragging or multi-touch on mobile devices, limiting the 'touch' experience the description promises.

💡 Add touchStarted()/touchMoved() handlers (or use p5's built-in touch-to-mouse mapping more explicitly) to trigger cells as fingers drag across the screen on mobile.

🔄 Code Flow

Code flow showing setup, draw, mousepressed, windowresized, switchmode, triggerrandomcell, cell, update, display, triggerexpand

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> overlaycheck[overlay-check] draw --> modefillbranch[mode-fill-branch] draw --> cellupdateloop[cell-update-loop] cellupdateloop --> update[update] update --> expandingstate[expanding-state] update --> contractingstate[contracting-state] draw --> idletimeoutcheck[idle-timeout-check] draw --> modecheck[mode-switch-check] overlaycheck --> overlaydismiss[overlay-dismiss] overlaydismiss --> mousepressed[mousepressed] mousepressed --> closestcellloop[closest-cell-loop] closestcellloop --> statecheck[state-check] statecheck --> triggerexpand[triggerexpand] triggerexpand --> expandingstate setup --> synthsetup[synth-setup] setup --> idlesequence[idle-sequence-setup] setup --> gridbuildloop[grid-build-loop] gridbuildloop --> cell[cell] setup --> switchmode[switchmode] switchmode --> idlebranch[idle-branch] switchmode --> interactivebranch[interactive-branch] draw --> resizecalcloop[resize-recalc-loop] resizecalcloop --> cell click setup href "#fn-setup" click draw href "#fn-draw" click overlaycheck href "#sub-overlay-check" click modefillbranch href "#sub-mode-fill-branch" click cellupdateloop href "#sub-cell-update-loop" click update href "#fn-update" click expandingstate href "#sub-expanding-state" click contractingstate href "#sub-contracting-state" click idletimeoutcheck href "#sub-idle-timeout-check" click modecheck href "#sub-mode-switch-check" click overlaydismiss href "#sub-overlay-dismiss" click mousepressed href "#fn-mousepressed" click closestcellloop href "#sub-closest-cell-loop" click statecheck href "#sub-state-check" click triggerexpand href "#fn-triggerexpand" click synthsetup href "#sub-synth-setup" click idlesequence href "#sub-idle-sequence-setup" click gridbuildloop href "#sub-grid-build-loop" click cell href "#fn-cell" click switchmode href "#fn-switchmode" click idlebranch href "#sub-idle-branch" click interactivebranch href "#sub-interactive-branch" click resizecalcloop href "#sub-resize-recalc-loop"

Preview

TooneTouch - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of TooneTouch - Code flow showing setup, draw, mousepressed, windowresized, switchmode, triggerrandomcell, cell, update, display, triggerexpand
Code Flow Diagram