AI Symmetry Canvas - Kaleidoscope Drawing with 6-Fold Mirroring - xelsed.ai

This sketch turns mouse movement into a mesmerizing mandala by mirroring every line segment you draw six times around the center of the canvas. Colors shift through the rainbow based on the angle from center, and a subtle fade effect leaves gentle trails as the pattern builds up.

🧪 Try This!

Experiment with the code by making these changes:

  1. Change the number of mirrored slices — Increasing numSymmetry adds more rotational copies, turning the hexagonal mandala into a denser flower-like pattern.
  2. Make trails last longer or vanish faster — Lowering fadeAlpha makes old strokes linger much longer before fading, while a higher value clears them almost instantly.
  3. Reverse the stroke thickness behavior — Swapping the output range of the map() call makes fast movements draw thick lines and slow movements draw thin ones, the opposite of the original feel.
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch turns your mouse into a mandala-drawing tool: every line segment you drag is instantly mirrored six times around the center of the canvas, producing the kind of rotational symmetry you'd see in a kaleidoscope. The color of each stroke shifts through the rainbow depending on the angle from the center, mouse speed controls how thick or thin the line is, and a semi-transparent overlay each frame creates a soft fading trail instead of a static drawing. It's built entirely from a handful of p5.js fundamentals - translate(), rotate(), push()/pop(), HSB color mode, and basic trigonometry with atan2().

The code is short but dense: setup() prepares an HSB canvas and precomputes the rotation angle needed for 6-fold symmetry, while draw() runs every frame to fade the background slightly and, if the mouse is pressed, draw one mirrored line segment per symmetry slice using a rotate-and-line-draw loop inside push()/pop(). Studying it teaches you how to combine translate/rotate transforms with a loop to fake perfect radial symmetry, how atan2() maps 2D direction into an angle for color mapping, and how map() with clamping converts a raw value like speed into a usable drawing parameter.

⚙️ How It Works

  1. When the page loads, setup() creates a full-window canvas, switches to HSB color mode (so hue can drive the rainbow effect), turns on rounded stroke caps, disables fill, paints a dark background, and calculates angleStep as one-sixth of a full circle (TWO_PI / 6).
  2. Every frame, draw() first layers a nearly-transparent dark rectangle over the whole canvas - because its alpha is low, old strokes only fade a little each frame, creating a trailing afterglow rather than an instant wipe.
  3. If the mouse button is held down, draw() measures how far the mouse moved since last frame (dist between mouseX/mouseY and pmouseX/pmouseY) and maps that speed to a stroke weight, so slow drags produce thick lines and fast flicks produce thin ones.
  4. It then computes the angle from the canvas center to the mouse using atan2(), maps that angle (-PI to PI) onto a hue (0-360), and sets the stroke color - so different directions from center paint different rainbow colors.
  5. Inside a push()/pop() block, the origin is translated to the canvas center, and a for-loop runs 6 times: each iteration rotates the coordinate system by angleStep and draws the same mouse-to-previous-mouse line segment, so the single stroke you drew appears mirrored at 6 evenly-spaced rotations around the center.
  6. Pressing 'R' or 'r' triggers keyPressed(), which repaints the dark background to instantly clear all drawn strokes, and resizing the browser triggers windowResized(), which resizes the canvas to match and clears it to avoid distortion.

🎓 Concepts You'll Learn

Rotational symmetry with translate/rotate/push/popHSB color mode and hue mappingTrigonometry with atan2() for angle calculationmap() for value remapping with clampingFade/trail effect via low-alpha overlayMouse interaction with mouseIsPressed, pmouseX/pmouseY

📝 Code Breakdown

setup()

setup() runs exactly once when the sketch starts. It's the right place to configure canvas size, color modes, and any values (like angleStep) that don't need to be recalculated every frame.

function setup() {
  // Create a canvas that fills the entire browser window
  createCanvas(windowWidth, windowHeight);

  // Set the color mode to HSB (Hue, Saturation, Brightness)
  // Hue: 0-360 degrees (color wheel)
  // Saturation, Brightness: 0-100%
  colorMode(HSB, 360, 100, 100);

  // Ensure line endings are rounded for a softer appearance
  strokeCap(ROUND);

  // Tell p5.js not to fill shapes, only draw their outlines
  noFill();

  // Set the initial background to a very dark grey
  background(0, 0, 10);

  // Calculate the angle for each symmetrical rotation step
  // TWO_PI is a full circle in radians (360 degrees)
  angleStep = TWO_PI / numSymmetry;
}
Line-by-line explanation (6 lines)
createCanvas(windowWidth, windowHeight);
Makes the canvas exactly as big as the browser window, so the drawing fills the whole screen.
colorMode(HSB, 360, 100, 100);
Switches p5's color system to Hue/Saturation/Brightness with hue ranging 0-360 (like a color wheel) instead of the default RGB 0-255, which makes rainbow color effects much easier to compute.
strokeCap(ROUND);
Makes the ends of each drawn line rounded instead of flat/square, giving strokes a softer, more organic look, especially at joints.
noFill();
Tells p5 not to fill any shapes with color - since this sketch only draws lines, there's nothing to fill.
background(0, 0, 10);
Paints the canvas with a very dark grey (near-black) using HSB values - hue and saturation are 0, brightness is 10%.
angleStep = TWO_PI / numSymmetry;
Precalculates how many radians to rotate for each symmetrical copy - dividing a full circle (TWO_PI) into numSymmetry equal slices.

draw()

draw() runs continuously (about 60 times per second) and is where all the per-frame logic lives: fading the background, reading mouse input, computing color/thickness from that input, and using push()/translate()/rotate()/pop() to fake perfect rotational symmetry without manually calculating each mirrored line's coordinates.

function draw() {
  // --- Fade Effect ---
  // Draw a semi-transparent dark rectangle over the canvas each frame
  // This creates a subtle trailing/fade effect for the drawn lines.
  fill(0, 0, 10, fadeAlpha); // HSB: dark grey with low alpha (transparency)
  rect(0, 0, width, height); // Cover the entire canvas
  noFill();                  // Switch back to no fill for lines


  // --- Interactive Drawing Logic ---
  // Only draw if the mouse is currently pressed and dragged
  if (mouseIsPressed) {
    // 1. Calculate Mouse Speed for Stroke Weight
    // The distance between the current mouse position and the previous mouse position
    let speed = dist(mouseX, mouseY, pmouseX, pmouseY);

    // Map the mouse speed to a stroke weight range.
    // Slower movements (speed close to 0) result in thicker lines (up to 8 pixels).
    // Faster movements (speed up to 30 pixels/frame) result in thinner lines (down to 1 pixel).
    // The 'true' argument clamps the values to ensure they stay within the specified range.
    strokeWeight(map(speed, 0, 30, 8, 1, true));

    // 2. Calculate Hue Based on Angle from Center
    // Determine the angle of the current mouse position relative to the center of the canvas
    // atan2() correctly handles angles in all four quadrants.
    let angle = atan2(mouseY - height / 2, mouseX - width / 2);

    // Map this angle (which ranges from -PI to PI radians, or -180 to 180 degrees)
    // to a hue value from 0 to 360 degrees for a rainbow effect.
    let hue = map(angle, -PI, PI, 0, 360);

    // Set the stroke color using the dynamically calculated hue,
    // a fixed saturation (80%), and full brightness (100%).
    stroke(hue, 80, 100);

    // --- Rotational Symmetry Drawing ---
    // 'push()' saves the current drawing state (translation, rotation, color, etc.)
    // This allows us to apply transformations for symmetry without affecting other parts of the sketch.
    push();

    // Move the origin (0,0) of the canvas to its center
    // All subsequent drawing commands will be relative to this new center.
    translate(width / 2, height / 2);

    // Loop 'numSymmetry' times to draw each symmetrical line
    for (let i = 0; i < numSymmetry; i++) {
      // Rotate the entire drawing canvas by 'angleStep' for each iteration
      // The rotation happens around the current origin (which is the center of the canvas).
      rotate(angleStep);

      // Draw the line segment.
      // The coordinates passed to line() must be relative to the *current* origin (the center).
      // So, we subtract width/2 and height/2 from the absolute mouse coordinates.
      line(pmouseX - width / 2, pmouseY - height / 2, mouseX - width / 2, mouseY - height / 2);
    }

    // 'pop()' restores the drawing state that was saved by 'push()',
    // returning the canvas origin and rotation to their default state.
    pop();
  }
}
Line-by-line explanation (15 lines)

🔧 Subcomponents:

calculation Fade Overlay fill(0, 0, 10, fadeAlpha); rect(0, 0, width, height);

Draws a nearly-transparent dark rectangle over everything each frame so older strokes gradually fade instead of persisting forever.

conditional Mouse Pressed Check if (mouseIsPressed) {

Only runs the drawing logic while the mouse button is held down and being dragged.

for-loop Symmetry Rotation Loop for (let i = 0; i < numSymmetry; i++) { rotate(angleStep); line(pmouseX - width / 2, pmouseY - height / 2, mouseX - width / 2, mouseY - height / 2); }

Rotates the coordinate system a little more each iteration and redraws the same line, producing numSymmetry evenly-spaced mirrored copies around the center.

fill(0, 0, 10, fadeAlpha);
Sets the fill color to dark grey with very low opacity (fadeAlpha out of 100), so the rectangle drawn next only slightly darkens whatever is beneath it.
rect(0, 0, width, height);
Draws that semi-transparent rectangle over the entire canvas, which is what makes old strokes gradually fade into the background over many frames.
noFill();
Turns fill back off so the line() calls later don't accidentally get a fill applied.
if (mouseIsPressed) {
Only executes the drawing code while a mouse button is held down, so you have to click-and-drag to draw.
let speed = dist(mouseX, mouseY, pmouseX, pmouseY);
Calculates how far the mouse moved since the last frame using the distance formula - a bigger number means faster movement.
strokeWeight(map(speed, 0, 30, 8, 1, true));
Converts speed into a line thickness: slow movement (speed near 0) maps to thick 8px lines, fast movement (speed 30+) maps to thin 1px lines; true clamps the result so it never goes outside 1-8.
let angle = atan2(mouseY - height / 2, mouseX - width / 2);
Computes the angle (in radians) from the canvas center to the current mouse position using atan2, which correctly handles all four directions (up, down, left, right).
let hue = map(angle, -PI, PI, 0, 360);
Converts that angle, which ranges from -PI to PI radians, into a hue value from 0 to 360 degrees - so drawing in different directions from center produces different rainbow colors.
stroke(hue, 80, 100);
Sets the line color using the computed hue with fixed high saturation (80%) and full brightness (100%), so colors are always vivid.
push();
Saves the current transformation and style state so the translate/rotate calls that follow don't permanently affect the rest of the sketch.
translate(width / 2, height / 2);
Moves the drawing origin (0,0) to the center of the canvas, so rotations happen around the middle of the screen instead of the top-left corner.
for (let i = 0; i < numSymmetry; i++) {
Repeats the enclosed code numSymmetry times (6 by default), once for each mirrored copy of the stroke.
rotate(angleStep);
Rotates the coordinate system by one slice of the circle each time through the loop - since this accumulates each iteration, the line ends up drawn at 6 different rotated positions.
line(pmouseX - width / 2, pmouseY - height / 2, mouseX - width / 2, mouseY - height / 2);
Draws a line from the previous mouse position to the current one, but with coordinates shifted so they're relative to the center (since translate() moved the origin there).
pop();
Restores the transformation state saved by push(), undoing the translate/rotate so the next frame starts fresh from the default coordinate system.

keyPressed()

keyPressed() is a p5.js function that automatically fires once every time any key is pressed. Checking the key variable inside it lets you trigger different actions for different keys.

function keyPressed() {
  // If the pressed key is 'R' (case-insensitive)
  if (key === 'R' || key === 'r') {
    // Clear the canvas by redrawing the dark background
    background(0, 0, 10);
  }
}
Line-by-line explanation (2 lines)

🔧 Subcomponents:

conditional R Key Check if (key === 'R' || key === 'r') {

Checks whether the key just pressed was 'R' or 'r' (case-insensitive) before clearing the canvas.

if (key === 'R' || key === 'r') {
Checks if the pressed key matches uppercase or lowercase 'r', so the reset works regardless of caps lock or shift.
background(0, 0, 10);
Repaints the entire canvas with the same dark grey used in setup(), instantly erasing all previously drawn strokes.

windowResized()

windowResized() is a p5.js function that fires automatically whenever the browser window changes size, letting you keep the canvas full-screen and responsive.

function windowResized() {
  // Resize the canvas to match the new dimensions of the browser window
  resizeCanvas(windowWidth, windowHeight);
  // Clear the canvas after resizing to prevent distortion of previous drawings
  background(0, 0, 10);
}
Line-by-line explanation (2 lines)
resizeCanvas(windowWidth, windowHeight);
Resizes the existing canvas to match the browser window's new width and height whenever the window is resized.
background(0, 0, 10);
Repaints the background after resizing, since resizing alone would leave old drawing content stretched or misaligned.

📦 Key Variables

fadeAlpha number

Controls the opacity of the fade-overlay rectangle drawn each frame, determining how quickly old strokes fade away.

let fadeAlpha = 5;
numSymmetry number

Sets how many rotationally mirrored copies of each stroke are drawn, defining the kaleidoscope's fold count.

let numSymmetry = 6;
angleStep number

Stores the rotation angle (in radians) applied per symmetry iteration, precalculated once in setup() as TWO_PI divided by numSymmetry.

let angleStep;

🔧 Potential Improvements (4)

Here are some ways this code could be enhanced:

BUG draw() - dist(mouseX, mouseY, pmouseX, pmouseY) and line()

On the very first frame the mouse is pressed, pmouseX/pmouseY can equal mouseX/mouseY (or be stale from before), which can cause a visible jump or a zero-length line/speed spike depending on browser behavior.

💡 Track a 'justPressed' flag set in mousePressed() to skip drawing a line on the first frame of a new stroke, avoiding an unwanted jump line from the last release point.

PERFORMANCE draw() - fill/rect fade overlay

A full-screen rect() with fill is drawn every single frame regardless of whether the user is drawing, which is a fairly expensive full-canvas redraw especially on large/high-DPI windows.

💡 This is a reasonable tradeoff for the fade effect, but on very large canvases consider using a lower-resolution offscreen buffer (createGraphics) for the fade layer to reduce fill-rate cost.

STYLE global scope

background(0, 0, 10) is duplicated in setup(), keyPressed(), and windowResized() as a hard-coded value.

💡 Store it as a color constant, e.g. `let bgColor;` set once in setup() with `bgColor = color(0,0,10);`, then reuse `background(bgColor)` everywhere for easier future tweaking.

FEATURE keyPressed()

There's no way to save or export the artwork the user creates, so a nice mandala is lost when the tab closes.

💡 Add a keyPressed() check for a key like 'S' that calls saveCanvas('mandala', 'png') so users can download their drawing.

🔄 Code Flow

Code flow showing setup, draw, keypressed, windowresized

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> fadeoverlay[fade-overlay] draw --> mousepressedcheck[mouse-pressed-check] draw --> symmetryloop[symmetry-loop] click setup href "#fn-setup" click draw href "#fn-draw" click fadeoverlay href "#sub-fade-overlay" click mousepressedcheck href "#sub-mouse-pressed-check" click symmetryloop href "#sub-symmetry-loop" mousepressedcheck -->|if mouse is pressed| symmetryloop mousepressedcheck -->|if mouse is not pressed| draw draw -->|continuous| draw draw --> rkeycheck[r-key-check] rkeycheck -->|if 'R' or 'r' pressed| setup rkeycheck -->|if other key pressed| draw draw --> windowresized[windowresized] click rkeycheck href "#sub-r-key-check" click windowresized href "#fn-windowresized"

❓ Frequently Asked Questions

What kind of visual patterns can I create with the AI Symmetry Canvas?

This sketch allows you to create mesmerizing mandala-like kaleidoscope patterns through 6-fold mirroring of your mouse strokes.

How do I interact with the AI Symmetry Canvas sketch?

Users can draw on the canvas by moving their mouse while pressed down, and press 'R' to clear the canvas and start fresh.

What creative coding concepts does this p5.js sketch showcase?

This sketch demonstrates the technique of rotational symmetry and dynamic color manipulation based on mouse movement, creating a visually engaging interactive experience.

Preview

AI Symmetry Canvas - Kaleidoscope Drawing with 6-Fold Mirroring - xelsed.ai - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of AI Symmetry Canvas - Kaleidoscope Drawing with 6-Fold Mirroring - xelsed.ai - Code flow showing setup, draw, keypressed, windowresized
Code Flow Diagram