Sketch 2025-12-12 01:03

This sketch creates an interactive 3D cosmic symphony where users click to spawn musical stars that orbit a central black hole under gravitational forces. Each star emits notes from the C Major Pentatonic scale, while an AI system analyzes the composition and suggests new celestial bodies to balance the arrangement. A shader-based animated nebula provides the background atmosphere.

🧪 Try This!

Experiment with the code by making these changes:

  1. Make stars much heavier — The mass parameter controls how strongly stars attract each other—heavier stars create more dramatic orbital spirals
  2. Change the black hole's pull — The G constant is the gravitational strength—higher values make gravity dominate and pull stars inward faster
  3. Make AI suggestions appear much faster — Lowering the cooldown time means new suggestions appear sooner after you create a star
  4. Make trails longer and more ethereal — Increasing trail length creates comet-like tails that persist longer, making motion more visible
  5. Add 10 initial stars instead of 5 — More initial stars make the composition more complex and give the AI more to analyze from the start
  6. Make the nebula background more visible — Raising nebula opacity makes the animated cosmic background more prominent
Prefer the full editor? Open it there →

📖 About This Sketch

This sketch combines three powerful libraries to create a unique audiovisual experience: Three.js renders 3D graphics of orbiting stars and a glowing black hole, Tone.js synthesizes musical notes in real time, and p5.js handles mouse interaction. When you click the canvas, a new star appears and begins orbiting the central gravitational well, emitting a musical note. The visual result is a constantly evolving constellation that also sounds like music—stars with trails of light, pulsing on beat, arranged by an AI system that listens to the composition you create.

The code is organized into three main sections: setup() and draw() handle p5.js initialization and AI logic, while drawThreeJS() runs the Three.js render loop at 60fps. The Star, BlackHole, and ShootingStar classes encapsulate all the physics, visuals, and sound for each cosmic body. By studying this sketch you will learn how to integrate multiple JavaScript libraries, implement gravitational N-body physics, drive sound synthesis from user interaction, and build a rule-based AI system that analyzes and responds to user input in real time.

⚙️ How It Works

  1. When the sketch loads, setup() initializes Three.js with a perspective camera and a WebGL renderer, creates the p5.js canvas hidden from view, and sets up Tone.js with three PolySynths and a MembraneSynth for different instrument sounds. A central BlackHole is placed at the origin and five initial Stars are scattered randomly in 3D space.
  2. Every frame, drawThreeJS() updates each Star's position by calculating gravitational forces from all other stars and the black hole (N-body physics), then renders the scene. Stars maintain a trail of particles that fade over time, creating comet-like visuals. The shader-based nebula background animates continuously using Perlin noise uniforms.
  3. When you click, a raycaster converts the 2D mouse position to a 3D world coordinate, and a new Star is created at that position. The star plays a note from the C Major Pentatonic scale and begins orbiting under gravity. If you drag, a ShootingStar is created with velocity based on your drag speed, emitting a quick high note.
  4. The AI system (analyzeCosmicComposition) runs periodically and examines all active stars, counting which notes and instruments are in use. Based on three rules—adding bass if lacking, filling gaps in the scale, or balancing instrument types—it suggests a new celestial body and displays it as a semi-transparent sphere you can click to accept.
  5. Stars glow brighter and pulse when they play a note, and emit a continuous oscillator tone when near the black hole. The black hole itself rotates its accretion disk and plays a low bass note periodically. All Three.js resources are properly disposed of when objects are removed to prevent memory leaks.

🎓 Concepts You'll Learn

Three.js 3D rendering and camera controlsN-body gravitational physics simulationTone.js sound synthesis and schedulingRaycasting for 3D mouse interactionShader materials for animated backgroundsBufferGeometry and Points for particle trailsClasses and object lifecycle managementRule-based AI analysis and suggestion system

📝 Code Breakdown

setup()

setup() runs once when the sketch starts. It initializes all three libraries (p5.js for input, Three.js for 3D graphics, and Tone.js for sound), creates the scene and camera, sets up synthesizers, and populates the world with the black hole and initial stars. Understanding this function teaches you how to bootstrap a multi-library creative coding project.

function setup() {
    // 1. p5.js setup (for mouse input, hidden canvas)
    p5Canvas = createCanvas(windowWidth, windowHeight);
    noCanvas(); // Hide the p5.js canvas

    // 2. three.js setup
    scene = new THREE.Scene();
    camera = new THREE.PerspectiveCamera(75, windowWidth / windowHeight, 0.1, 2000);
    camera.position.z = 500; // Initial camera position

    renderer = new THREE.WebGLRenderer({ antialias: true }); // Enable antialiasing for smoother edges
    renderer.setSize(windowWidth, windowHeight);
    document.body.appendChild(renderer.domElement); // Add three.js canvas to the DOM

    // OrbitControls for camera navigation
    controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true; // Give a sense of weight to the controls
    controls.dampingFactor = 0.05;

    // Lights
    const ambientLight = new THREE.AmbientLight(0x404040); // Soft ambient light
    scene.add(ambientLight);
    const pointLight = new THREE.PointLight(0xffffff, 1, 1000); // Point light attached to camera
    camera.add(pointLight);
    scene.add(camera);

    // 3. Tone.js setup
    Tone.start(); // Start the Web Audio Context
    masterVolume = new THREE.Volume(-10).toDestination(); // Master volume control

    // Initialize instruments (PolySynths for multiple notes)
    instruments.push(new Tone.PolySynth(Tone.Synth, {
        oscillator: { type: 'sine' },
        envelope: { attack: 0.01, decay: 0.5, sustain: 0.1, release: 1 }
    }).connect(masterVolume)); // Gentle synth
    instruments.push(new Tone.PolySynth(Tone.Synth, {
        oscillator: { type: 'sawtooth' },
        envelope: { attack: 0.01, decay: 0.8, sustain: 0.3, release: 1.5 }
    }).connect(masterVolume)); // Brighter synth
    instruments.push(new Tone.PolySynth(Tone.MembraneSynth, {
        pitchDecay: 0.008,
        octaves: 2,
        envelope: { attack: 0.001, decay: 0.5, sustain: 0.01, release: 0.8 },
        portamento: 0
    }).connect(masterVolume)); // Percussive/bass synth

    // 4. Populate initial scene
    // Add a central black hole
    blackHoles.push(new BlackHole(new THREE.Vector3(0, 0, 0), 100000, 30));
    scene.add(blackHoles[0].mesh);
    scene.add(blackHoles[0].accretionDiskMesh); // Add accretion disk

    // Add some initial stars
    for (let i = 0; i < 5; i++) {
        const pos = new THREE.Vector3(random(-300, 300), random(-300, 300), random(-300, 300));
        const star = new Star(pos, random(50, 200), random(5, 15));
        stars.push(star);
        scene.add(star.mesh);
        scene.add(star.trailMesh); // Add star trail
    }

    // 5. Nebula Background (Shader Material)
    const nebulaUniforms = {
      time: { value: 0.0 },
      color1: { value: new THREE.Color(0x1a0a33) }, // Deep purple
      color2: { value: new THREE.Color(0x330a1a) }, // Deep red
      color3: { value: new THREE.Color(0x0a331a) }, // Deep green
      color4: { value: new THREE.Color(0x0a1a33) }  // Deep blue
    };
    const nebulaMaterial = new THREE.ShaderMaterial({
      uniforms: nebulaUniforms,
      vertexShader: nebulaVertexShader,
      fragmentShader: nebulaFragmentShader,
      side: THREE.BackSide, // Render on the inside of the sphere
      transparent: true,
      opacity: 0.8
    });
    const nebulaGeometry = new THREE.SphereGeometry(1500, 32, 32); // Large sphere around the scene
    nebulaBackgroundMesh = new THREE.Mesh(nebulaGeometry, nebulaMaterial);
    scene.add(nebulaBackgroundMesh);

    // 6. Start the three.js render loop
    drawThreeJS();
}
Line-by-line explanation (18 lines)

🔧 Subcomponents:

initialization p5.js Canvas Setup p5Canvas = createCanvas(windowWidth, windowHeight);

Creates a hidden p5.js canvas for capturing mouse input without rendering visuals

initialization Three.js Scene Initialization scene = new THREE.Scene();

Creates the Three.js scene that will hold all 3D objects and the renderer

loop Instrument Creation instruments.push(new Tone.PolySynth(Tone.Synth, {...}).connect(masterVolume));

Creates three different synthesizers with different sound characteristics to be assigned to stars

for-loop Spawn Initial Stars for (let i = 0; i < 5; i++) { const pos = new THREE.Vector3(...); ... }

Populates the scene with five randomly positioned stars to start the composition

initialization Nebula Shader Setup const nebulaMaterial = new THREE.ShaderMaterial({...});

Creates the animated animated background nebula using GLSL shaders that procedurally generate colorful noise

p5Canvas = createCanvas(windowWidth, windowHeight);
Creates a p5.js canvas the size of the window, but the next line hides it—we only use p5.js for mouse input handling
noCanvas();
Hides the p5.js canvas so only the Three.js renderer is visible
scene = new THREE.Scene();
Creates an empty Three.js scene object that will contain all 3D objects
camera = new THREE.PerspectiveCamera(75, windowWidth / windowHeight, 0.1, 2000);
Creates a perspective camera with a 75-degree field of view and the window's aspect ratio
camera.position.z = 500;
Moves the camera 500 units away from the origin so we can see the entire scene
renderer = new THREE.WebGLRenderer({ antialias: true });
Creates a WebGL renderer that will draw the 3D scene; antialias smooths edges for prettier visuals
document.body.appendChild(renderer.domElement);
Adds the Three.js canvas to the web page's DOM so it appears in the browser
controls = new THREE.OrbitControls(camera, renderer.domElement);
Enables interactive camera controls so you can rotate, zoom, and pan the view with the mouse
controls.enableDamping = true;
Adds inertia to camera movement so it feels smooth and weighty instead of snappy
Tone.start();
Initializes the Web Audio API context, required before creating any Tone.js synthesizers
masterVolume = new THREE.Volume(-10).toDestination();
Creates a master volume control and connects it to the speakers; -10 is a moderate starting volume
instruments.push(new Tone.PolySynth(Tone.Synth, { oscillator: { type: 'sine' }, ... }).connect(masterVolume));
Creates the first instrument: a gentle sine-wave synth with smooth attack and release, perfect for melodic notes
blackHoles.push(new BlackHole(new THREE.Vector3(0, 0, 0), 100000, 30));
Creates a black hole at the origin with enormous mass (100000) and a visible size of 30 units
scene.add(blackHoles[0].mesh);
Adds the black hole's visual mesh to the Three.js scene so it will be rendered
for (let i = 0; i < 5; i++) { ... }
Loops 5 times, creating an initial constellation of random stars to demonstrate the system
const nebulaMaterial = new THREE.ShaderMaterial({ uniforms: nebulaUniforms, vertexShader: nebulaVertexShader, fragmentShader: nebulaFragmentShader, ... });
Creates a custom shader material that runs custom GLSL code on the GPU to generate an animated procedural nebula background
nebulaBackgroundMesh = new THREE.Mesh(nebulaGeometry, nebulaMaterial);
Wraps the nebula geometry and shader material into a mesh, and adds it to the scene as a backdrop
drawThreeJS();
Starts the Three.js render loop, which will continuously update and draw the 3D scene

draw()

The p5.js draw() function runs 60 times per second and is perfect for managing game-like state and logic. Here, it throttles the AI suggestion system using a cooldown timer—a common pattern for preventing actions from happening too often.

🔬 This code controls how often the AI makes suggestions. What happens if you change the condition from aiSuggestionCooldown === 0 to aiSuggestionCooldown === 0 && stars.length < 15? Try it and predict: will the AI suggest more or fewer stars when the canvas is already crowded?

    aiSuggestionCooldown = max(0, aiSuggestionCooldown - 1);

    // If no suggestion is active and cooldown is over, generate a new one
    if (aiSuggestionCooldown === 0 && !aiSuggestion) {
        aiSuggestion = suggestNewCelestialBody();
        displayAISuggestion();
    }
function draw() {
    // Update AI suggestion cooldown
    aiSuggestionCooldown = max(0, aiSuggestionCooldown - 1);

    // If no suggestion is active and cooldown is over, generate a new one
    if (aiSuggestionCooldown === 0 && !aiSuggestion) {
        aiSuggestion = suggestNewCelestialBody();
        displayAISuggestion();
    }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Cooldown Timer aiSuggestionCooldown = max(0, aiSuggestionCooldown - 1);

Decrements the AI cooldown timer each frame, preventing suggestions from coming too frequently

conditional AI Suggestion Logic if (aiSuggestionCooldown === 0 && !aiSuggestion) { aiSuggestion = suggestNewCelestialBody(); displayAISuggestion(); }

Checks if enough time has passed and no suggestion is currently showing, then generates and displays a new suggestion

aiSuggestionCooldown = max(0, aiSuggestionCooldown - 1);
Reduces the cooldown timer by 1 each frame, using max() to prevent it from going below 0
if (aiSuggestionCooldown === 0 && !aiSuggestion) {
Checks two conditions: cooldown is zero (enough time has passed) AND no suggestion is currently active
aiSuggestion = suggestNewCelestialBody();
Calls the AI function to analyze the current composition and generate a suggestion object
displayAISuggestion();
Renders the suggestion as a semi-transparent sphere in the 3D scene so the user can see and click it

drawThreeJS()

drawThreeJS() is called every animation frame (about 60 times per second) via requestAnimationFrame, which is the browser's preferred way to animate 3D graphics. It updates all dynamic objects, removes dead ones, and renders the frame. This function is the heartbeat of the visualization.

🔬 This loop updates every star each frame. What would happen if you commented out the star.updatePulse() line? The stars would move normally, but what visual effect would you lose?

    for (const star of stars) {
        star.update(stars, blackHoles);
        star.updatePulse(); // Update star pulse animation
    }
function drawThreeJS() {
    requestAnimationFrame(drawThreeJS); // Continuously call this function

    // Update cosmic bodies
    for (const star of stars) {
        star.update(stars, blackHoles);
        star.updatePulse(); // Update star pulse animation
    }
    for (const bh of blackHoles) {
        bh.update();
    }
    for (const ss of shootingStars) {
        ss.update();
    }
    // Remove finished shooting stars and dispose of their Three.js resources
    shootingStars = shootingStars.filter(ss => {
        if (ss.isFinished()) {
            ss.dispose();
            return false;
        }
        return true;
    });

    // Update nebula shader time uniform
    if (nebulaBackgroundMesh) {
      nebulaBackgroundMesh.material.uniforms.time.value += 0.01;
    }

    controls.update(); // Update OrbitControls (required if controls.enableDamping is true)
    renderer.render(scene, camera); // Render the three.js scene
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

for-loop Update All Stars for (const star of stars) { star.update(stars, blackHoles); star.updatePulse(); }

Iterates through all stars and updates their physics and visual pulse animations

for-loop Update Black Holes for (const bh of blackHoles) { bh.update(); }

Updates each black hole's rotating accretion disk

for-loop Update Shooting Stars for (const ss of shootingStars) { ss.update(); }

Moves each shooting star forward and updates its fading trail

filter Clean Up Dead Shooting Stars shootingStars = shootingStars.filter(ss => { if (ss.isFinished()) { ss.dispose(); return false; } return true; });

Removes shooting stars that have finished their lifespan and properly disposes of their Three.js resources

calculation Animate Nebula if (nebulaBackgroundMesh) { nebulaBackgroundMesh.material.uniforms.time.value += 0.01; }

Increments the time uniform sent to the nebula shader, creating the flowing animation effect

requestAnimationFrame(drawThreeJS);
Schedules this function to be called again on the next frame (about 60fps), creating a continuous loop
for (const star of stars) {
Loops through each star in the stars array
star.update(stars, blackHoles);
Updates the star's position based on gravitational forces from all other stars and black holes
star.updatePulse();
Updates the star's pulsing glow animation if it recently played a note
for (const bh of blackHoles) { bh.update(); }
Updates each black hole (rotating its accretion disk and scrolling its texture)
for (const ss of shootingStars) { ss.update(); }
Advances each shooting star's position and updates its fading trail effect
shootingStars = shootingStars.filter(ss => { if (ss.isFinished()) { ss.dispose(); return false; } return true; });
Creates a new array containing only shooting stars that are still alive, removing and cleaning up finished ones
if (nebulaBackgroundMesh) { nebulaBackgroundMesh.material.uniforms.time.value += 0.01; }
Passes an incrementing time value to the shader, which uses it to animate the nebula colors and patterns
controls.update();
Updates the camera based on mouse input and applies damping (inertia) to smooth movement
renderer.render(scene, camera);
Renders the entire Three.js scene from the camera's viewpoint and displays it on screen

mousePressed()

mousePressed() is called once when the user clicks. This function uses Three.js raycasting to convert 2D screen clicks into 3D world interactions—a fundamental technique for interactive 3D graphics. You can also see how clicking either accepts an AI suggestion or creates a new star, with proper resource cleanup in both cases.

🔬 This code checks if you clicked the AI suggestion. What if you wanted to dismiss a suggestion WITHOUT creating a star? How would you modify this to delete the suggestion mesh when you click elsewhere in the scene?

    // Check if the user clicked on the AI suggestion mesh
    if (aiSuggestion && aiSuggestionMesh && raycaster.intersectObjects([aiSuggestionMesh]).length > 0) {
        // User accepted the AI suggestion
        aiSuggestionAccepted = true;
        createSuggestedBody();
        aiSuggestion = null; // Clear suggestion
function mousePressed() {
    // Convert mouse coordinates to three.js world coordinates for raycasting
    const mouse = new THREE.Vector2(
        (mouseX / windowWidth) * 2 - 1,
        -(mouseY / windowHeight) * 2 + 1
    );
    const raycaster = new THREE.Raycaster();
    raycaster.setFromCamera(mouse, camera);

    // Check if the user clicked on the AI suggestion mesh
    if (aiSuggestion && aiSuggestionMesh && raycaster.intersectObjects([aiSuggestionMesh]).length > 0) {
        // User accepted the AI suggestion
        aiSuggestionAccepted = true;
        createSuggestedBody();
        aiSuggestion = null; // Clear suggestion
        if (aiSuggestionMesh) {
            scene.remove(aiSuggestionMesh);
            aiSuggestionMesh.geometry.dispose();
            aiSuggestionMesh.material.dispose();
            aiSuggestionMesh = null;
        }
        aiSuggestionCooldown = AI_COOLDOWN_TIME; // Reset cooldown
        return; // Prevent creating a new star if suggestion was clicked
    }

    // If not clicking suggestion, create a new star at the clicked position
    // We project the mouse click onto a plane at z=0 to get a 3D position
    const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
    const intersection = new THREE.Vector3();
    raycaster.ray.intersectPlane(plane, intersection);

    if (intersection) {
        const star = new Star(intersection, random(50, 200), random(5, 15));
        stars.push(star);
        scene.add(star.mesh);
        scene.add(star.trailMesh); // Add star trail
        star.playNote(); // Play the star's note on creation
        aiSuggestionCooldown = AI_COOLDOWN_TIME; // Reset AI cooldown
        if (aiSuggestionMesh) {
            scene.remove(aiSuggestionMesh);
            aiSuggestionMesh.geometry.dispose();
            aiSuggestionMesh.material.dispose();
            aiSuggestionMesh = null;
        }
        aiSuggestion = null; // Clear suggestion when user manually adds
    }
}
Line-by-line explanation (14 lines)

🔧 Subcomponents:

calculation Mouse to 3D Conversion const mouse = new THREE.Vector2((mouseX / windowWidth) * 2 - 1, -(mouseY / windowHeight) * 2 + 1);

Transforms 2D screen coordinates into Three.js normalized device coordinates (-1 to 1)

initialization Raycaster Creation const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(mouse, camera);

Creates a ray from the camera through the mouse position to detect 3D object clicks

conditional AI Suggestion Click Detection if (aiSuggestion && aiSuggestionMesh && raycaster.intersectObjects([aiSuggestionMesh]).length > 0) {

Checks if the click ray intersects the AI suggestion sphere

calculation Project Click onto Plane const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); raycaster.ray.intersectPlane(plane, intersection);

Projects the mouse ray onto the z=0 plane to get a 3D world position for star creation

const mouse = new THREE.Vector2((mouseX / windowWidth) * 2 - 1, -(mouseY / windowHeight) * 2 + 1);
Converts the 2D mouse position (0 to windowWidth/Height) into Three.js normalized coordinates (-1 to 1), which the raycaster expects
const raycaster = new THREE.Raycaster();
Creates a raycaster object, which can trace a ray through 3D space and detect intersections with objects
raycaster.setFromCamera(mouse, camera);
Tells the raycaster to emit a ray from the camera through the given mouse position
if (aiSuggestion && aiSuggestionMesh && raycaster.intersectObjects([aiSuggestionMesh]).length > 0) {
Checks three things: a suggestion exists, its mesh exists, and the ray hits the mesh—if all true, the user clicked the suggestion
createSuggestedBody();
Creates the AI's suggested star and adds it to the scene
aiSuggestion = null;
Clears the suggestion so a new one can be generated after the cooldown expires
scene.remove(aiSuggestionMesh);
Removes the semi-transparent suggestion sphere from the scene so it disappears
aiSuggestionMesh.geometry.dispose();
Properly deallocates the geometry's GPU memory—important for preventing memory leaks
aiSuggestionMesh.material.dispose();
Deallocates the material's GPU memory
const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
Creates a plane perpendicular to the z-axis at z=0 (the center of the scene)
raycaster.ray.intersectPlane(plane, intersection);
Finds where the ray from the camera through the mouse intersects the z=0 plane and stores it in 'intersection'
if (intersection) {
Only creates a star if the intersection was found (should always be true unless the camera is behind the plane)
const star = new Star(intersection, random(50, 200), random(5, 15));
Creates a new Star object at the intersection point with a random mass and size
star.playNote();
Triggers the new star's synth to play its assigned note and pulse visually

mouseDragged()

mouseDragged() is called every frame while the mouse is held down and moving. By tracking the previous mouse position, you can calculate drag direction and magnitude—a powerful interaction pattern for gesture-based input. Here it's used to create shooting stars with velocity matching your physical gesture.

🔬 These lines calculate how fast a shooting star should go based on your drag distance. What happens if you change the speed range from (5, 20) to (1, 50)? Slower vs. faster stars? Try both and see which feels better.

        const direction = new THREE.Vector2().subVectors(end, start).normalize();
        const speed = map(direction.length(), 0, sqrt(2), 5, 20); // Map drag length to shooting star speed
let mousePrevX, mousePrevY;
function mouseDragged() {
    if (mousePrevX !== undefined && mousePrevY !== undefined) {
        const start = new THREE.Vector3(
            (mousePrevX / windowWidth) * 2 - 1,
            -(mousePrevY / windowHeight) * 2 + 1,
            0
        );
        const end = new THREE.Vector3(
            (mouseX / windowWidth) * 2 - 1,
            -(mouseY / windowHeight) * 2 + 1,
            0
        );

        const direction = new THREE.Vector2().subVectors(end, start).normalize();
        const speed = map(direction.length(), 0, sqrt(2), 5, 20); // Map drag length to shooting star speed

        // Project start of drag onto a plane at z=0
        const raycaster = new THREE.Raycaster();
        raycaster.setFromCamera(start, camera);
        const plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
        const intersection = new THREE.Vector3();
        raycaster.ray.intersectPlane(plane, intersection);

        if (intersection) {
            const shootingStar = new ShootingStar(intersection, direction.multiplyScalar(speed));
            shootingStars.push(shootingStar);
            scene.add(shootingStar.mesh);
            shootingStar.playNote(); // Shooting stars also make a sound
            aiSuggestionCooldown = AI_COOLDOWN_TIME; // Reset AI cooldown
        }
    }
    mousePrevX = mouseX;
    mousePrevY = mouseY;
}
Line-by-line explanation (8 lines)

🔧 Subcomponents:

conditional Previous Position Validation if (mousePrevX !== undefined && mousePrevY !== undefined) {

Ensures previous mouse coordinates exist before calculating drag direction

calculation Drag Direction and Speed const direction = new THREE.Vector2().subVectors(end, start).normalize(); const speed = map(direction.length(), 0, sqrt(2), 5, 20);

Calculates the drag direction and maps drag distance to shooting star velocity

initialization Create Shooting Star const shootingStar = new ShootingStar(intersection, direction.multiplyScalar(speed));

Creates a new shooting star with velocity based on the drag gesture

if (mousePrevX !== undefined && mousePrevY !== undefined) {
Checks that previous mouse coordinates have been set (this prevents errors on the first frame of dragging)
const start = new THREE.Vector3((mousePrevX / windowWidth) * 2 - 1, -(mousePrevY / windowHeight) * 2 + 1, 0);
Converts the previous mouse position to normalized device coordinates and creates a 3D point at z=0
const end = new THREE.Vector3((mouseX / windowWidth) * 2 - 1, -(mouseY / windowHeight) * 2 + 1, 0);
Converts the current mouse position to normalized device coordinates for the end of the drag
const direction = new THREE.Vector2().subVectors(end, start).normalize();
Calculates the drag vector from start to end and normalizes it to a unit direction (length 1)
const speed = map(direction.length(), 0, sqrt(2), 5, 20);
Maps the drag distance (0 to √2, the max distance in normalized space) to a speed (5 to 20 pixels per frame)
const shootingStar = new ShootingStar(intersection, direction.multiplyScalar(speed));
Creates a shooting star with the calculated position and velocity
shootingStar.playNote();
Triggers the shooting star to emit a quick, high musical note
mousePrevX = mouseX;
Stores the current mouse x position for the next call to mouseDragged()

analyzeCosmicComposition()

This function is the AI's eyes—it analyzes the current state of the composition and returns structured data that the suggestion system uses to make intelligent decisions. Understanding how to analyze your data before making decisions is fundamental to building systems that adapt to user input.

function analyzeCosmicComposition() {
    const noteCounts = {};
    const instrumentCounts = {};
    let totalMass = 0;
    let totalStars = stars.length;

    // Initialize counts
    musicScale.forEach(note => noteCounts[note] = 0);
    instruments.forEach((_, i) => instrumentCounts[i] = 0);

    // Count stars by note and instrument
    for (const star of stars) {
        noteCounts[star.note]++;
        instrumentCounts[star.instrumentIndex]++;
        totalMass += star.mass;
    }

    const composition = {
        noteCounts,
        instrumentCounts,
        totalMass,
        totalStars,
        density: totalMass / (windowWidth * windowHeight * 1000) // Crude 3D density
    };

    console.log("Cosmic Composition:", composition);
    return composition;
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

initialization Initialize Counters musicScale.forEach(note => noteCounts[note] = 0); instruments.forEach((_, i) => instrumentCounts[i] = 0);

Sets all note and instrument counts to zero before tallying

for-loop Count Stars by Note and Instrument for (const star of stars) { noteCounts[star.note]++; instrumentCounts[star.instrumentIndex]++; totalMass += star.mass; }

Tallies how many stars play each note, use each instrument, and sums their total mass

const noteCounts = {};
Creates an empty object to store the count of stars playing each musical note
const instrumentCounts = {};
Creates an empty object to store how many stars use each synthesizer
musicScale.forEach(note => noteCounts[note] = 0);
Initializes every note in the C Major Pentatonic scale to 0 in the noteCounts object
instruments.forEach((_, i) => instrumentCounts[i] = 0);
Initializes each instrument index to 0; the underscore ignores the actual synth object, we only need the index
for (const star of stars) {
Loops through every star currently in the scene
noteCounts[star.note]++;
Increments the counter for the note this star plays
instrumentCounts[star.instrumentIndex]++;
Increments the counter for the instrument this star uses
totalMass += star.mass;
Adds this star's mass to the running total
const composition = { noteCounts, instrumentCounts, totalMass, totalStars, density: totalMass / (windowWidth * windowHeight * 1000) };
Packages all the analysis data into a single object and calculates crude density (total mass divided by screen area)
console.log("Cosmic Composition:", composition);
Prints the analysis to the browser console so you can inspect what the AI 'sees' when making decisions

suggestNewCelestialBody()

This function implements the AI's decision-making logic. It uses a priority-based rule system: first check for missing bass, then fill scale gaps, then balance instruments. Each rule is independent and returns immediately, so only the highest-priority applicable rule executes. This is a simple but effective pattern for AI that feels intelligent without being complex.

🔬 This rule triggers if you're missing bass notes. What happens if you comment out the 'return suggestion' line at the end of each rule? Will the AI use all three rules in order, or just the last one?

    // Rule 1: Encourage bass if lacking (C4 or G4)
    if (composition.noteCounts['C4'] < 1 || composition.noteCounts['G4'] < 1) {
        suggestion.note = random(['C4', 'G4']);
        suggestion.size = random(20, 30);
        suggestion.mass = suggestion.size * 10;
        suggestion.color = random(['#FF0000', '#0000FF']); // Red or Blue for bass
        suggestion.instrumentIndex = 2; // MembraneSynth for bass/percussion
        suggestion.position.set(random(-200, 200), random(-200, 200), random(-200, 200));
        console.log("AI Suggestion: Need more bass!");
        return suggestion;
    }
function suggestNewCelestialBody() {
    const composition = analyzeCosmicComposition();
    let suggestion = { type: 'star', note: '', size: 0, color: '', instrumentIndex: 0, position: new THREE.Vector3() };

    // Rule 1: Encourage bass if lacking (C4 or G4)
    if (composition.noteCounts['C4'] < 1 || composition.noteCounts['G4'] < 1) {
        suggestion.note = random(['C4', 'G4']);
        suggestion.size = random(20, 30);
        suggestion.mass = suggestion.size * 10;
        suggestion.color = random(['#FF0000', '#0000FF']); // Red or Blue for bass
        suggestion.instrumentIndex = 2; // MembraneSynth for bass/percussion
        suggestion.position.set(random(-200, 200), random(-200, 200), random(-200, 200));
        console.log("AI Suggestion: Need more bass!");
        return suggestion;
    }

    // Rule 2: Fill gaps in the scale
    const missingNotes = musicScale.filter(note => composition.noteCounts[note] === 0);
    if (missingNotes.length > 0) {
        suggestion.note = random(missingNotes);
        suggestion.size = random(8, 18);
        suggestion.mass = suggestion.size * 5;
        suggestion.color = random(['#FFFF00', '#00FFFF']); // Yellow or Cyan for mid-range
        suggestion.instrumentIndex = random([0, 1]); // Synth or Sawtooth Synth
        suggestion.position.set(random(-200, 200), random(-200, 200), random(-200, 200));
        console.log("AI Suggestion: Fill scale gaps!");
        return suggestion;
    }

    // Rule 3: Balance instrument types
    const leastUsedInstrumentIndex = Object.values(composition.instrumentCounts).indexOf(min(Object.values(composition.instrumentCounts)));
    suggestion.instrumentIndex = leastUsedInstrumentIndex;
    suggestion.note = random(musicScale);
    suggestion.size = random(8, 25);
    suggestion.mass = suggestion.size * 5;
    suggestion.color = random(['#FF00FF', '#FF8000']); // Magenta or Orange for balance
    suggestion.position.set(random(-200, 200), random(-200, 200), random(-200, 200));
    console.log("AI Suggestion: Balance instruments!");
    return suggestion;
}
Line-by-line explanation (9 lines)

🔧 Subcomponents:

conditional Rule 1: Add Bass if Lacking if (composition.noteCounts['C4'] < 1 || composition.noteCounts['G4'] < 1) {

Suggests a bass note if neither C4 nor G4 exists in the composition

conditional Rule 2: Fill Scale Gaps const missingNotes = musicScale.filter(note => composition.noteCounts[note] === 0); if (missingNotes.length > 0) {

Suggests a note that's missing from the current composition

conditional Rule 3: Balance Instruments const leastUsedInstrumentIndex = Object.values(composition.instrumentCounts).indexOf(min(Object.values(composition.instrumentCounts)));

Suggests a star that uses the least-used instrument to balance the timbre

const composition = analyzeCosmicComposition();
Analyzes the current state of the composition to see which notes and instruments are in use
if (composition.noteCounts['C4'] < 1 || composition.noteCounts['G4'] < 1) {
Checks if either C4 or G4 (the bass notes) are missing—if so, prioritize bass
suggestion.note = random(['C4', 'G4']);
Picks either C4 or G4 at random for the suggested star
suggestion.instrumentIndex = 2;
Assigns the MembraneSynth (index 2), which produces punchy, percussive bass sounds
const missingNotes = musicScale.filter(note => composition.noteCounts[note] === 0);
Creates an array of all notes in the scale that appear 0 times (are missing entirely)
if (missingNotes.length > 0) {
If there are any missing notes, execute Rule 2
suggestion.note = random(missingNotes);
Picks one of the missing notes at random to fill a gap
const leastUsedInstrumentIndex = Object.values(composition.instrumentCounts).indexOf(min(Object.values(composition.instrumentCounts)));
Finds the instrument with the lowest usage count by converting the counts object to an array and finding the minimum
suggestion.instrumentIndex = leastUsedInstrumentIndex;
Assigns the least-used instrument to balance the timbre

displayAISuggestion()

This function visualizes the AI's suggestion by creating a semi-transparent sphere. It demonstrates proper resource cleanup (dispose) and the use of additive blending for a glowing visual effect. The transparency and color make the suggestion stand out without blocking the view of the scene.

function displayAISuggestion() {
    if (!aiSuggestion) return;

    // Remove any previous suggestion mesh
    if (aiSuggestionMesh) {
        scene.remove(aiSuggestionMesh);
        aiSuggestionMesh.geometry.dispose();
        aiSuggestionMesh.material.dispose();
    }

    // Create a transparent mesh for the suggestion
    const geometry = new THREE.SphereGeometry(aiSuggestion.size, 32, 32);
    const material = new THREE.MeshBasicMaterial({
        color: new THREE.Color(aiSuggestion.color),
        transparent: true,
        opacity: 0.3,
        blending: THREE.AdditiveBlending // For a glowing, ethereal look
    });
    aiSuggestionMesh = new THREE.Mesh(geometry, material);
    aiSuggestionMesh.position.copy(aiSuggestion.position);
    scene.add(aiSuggestionMesh);
    console.log("AI suggestion displayed. Click to accept.");
}
Line-by-line explanation (7 lines)

🔧 Subcomponents:

conditional Clean Up Previous Mesh if (aiSuggestionMesh) { scene.remove(aiSuggestionMesh); aiSuggestionMesh.geometry.dispose(); aiSuggestionMesh.material.dispose(); }

Removes the old suggestion mesh and frees its GPU resources before creating a new one

initialization Create Suggestion Sphere const geometry = new THREE.SphereGeometry(aiSuggestion.size, 32, 32); const material = new THREE.MeshBasicMaterial({...});

Creates a sphere mesh with the suggestion's properties (size, color, transparency)

if (!aiSuggestion) return;
Early exit if there's no suggestion to display—prevents errors
if (aiSuggestionMesh) { scene.remove(aiSuggestionMesh); aiSuggestionMesh.geometry.dispose(); aiSuggestionMesh.material.dispose(); }
If an old suggestion mesh exists, remove it from the scene and free its memory
const geometry = new THREE.SphereGeometry(aiSuggestion.size, 32, 32);
Creates a sphere with the suggested size; 32 segments give it smooth curves
const material = new THREE.MeshBasicMaterial({ color: new THREE.Color(aiSuggestion.color), transparent: true, opacity: 0.3, blending: THREE.AdditiveBlending });
Creates a material with the suggestion's color, semi-transparent (0.3 opacity), and additive blending for a glowing effect
aiSuggestionMesh = new THREE.Mesh(geometry, material);
Combines the geometry and material into a mesh object
aiSuggestionMesh.position.copy(aiSuggestion.position);
Positions the mesh at the location specified by the suggestion
scene.add(aiSuggestionMesh);
Adds the mesh to the Three.js scene so it will be rendered

createSuggestedBody()

This function converts the AI suggestion into an actual Star object and adds it to the world. It uses destructuring to cleanly extract properties and includes a placeholder for future expansion (black holes, nebulae, etc.). The immediate playNote() call provides instant audio feedback to the user.

function createSuggestedBody() {
    if (!aiSuggestion) return;
    const { type, position, mass, size, color, note, instrumentIndex } = aiSuggestion;

    if (type === 'star') {
        const star = new Star(position, mass, size, color, note, instrumentIndex);
        stars.push(star);
        scene.add(star.mesh);
        scene.add(star.trailMesh); // Add star trail
        star.playNote();
        console.log("AI suggestion accepted: New Star created!");
    } else if (type === 'blackHole') {
        // For simplicity, the AI only suggests stars in this implementation.
        // A more advanced AI could suggest black holes or other bodies.
        console.warn("AI suggested a black hole, but currently only supports stars.");
    }
}
Line-by-line explanation (6 lines)

🔧 Subcomponents:

calculation Destructure Suggestion const { type, position, mass, size, color, note, instrumentIndex } = aiSuggestion;

Extracts all properties from the suggestion object for easier access

conditional Create Star if Applicable if (type === 'star') { const star = new Star(...); ... }

Instantiates a Star with the suggestion's properties and adds it to the scene

if (!aiSuggestion) return;
Early exit if there's no active suggestion
const { type, position, mass, size, color, note, instrumentIndex } = aiSuggestion;
Uses destructuring to extract all properties from the suggestion object at once, making the code cleaner
if (type === 'star') {
Checks if the suggestion is for a star (the only type currently supported)
const star = new Star(position, mass, size, color, note, instrumentIndex);
Creates a new Star object with all the properties the AI suggested
stars.push(star);
Adds the star to the stars array so it will be updated and rendered each frame
star.playNote();
Immediately plays the star's note so the user hears the result of accepting the suggestion

class Star

The Star class is the core of the simulation. It combines three-dimensional physics (gravitational N-body), real-time 3D graphics (mesh and trail rendering), and music synthesis (synth and oscillator). The update() method is a physics simulation loop, updatePulse() is a tween animation loop, and the dispose() method shows best practices for cleaning up resources to prevent memory leaks.

🔬 This loop applies gravitational force from the black hole. What happens if you remove the 'if (distanceSq > bh.size * bh.size)' check? Stars would fly straight into the black hole—try it and see what breaks!

        // Gravity from black holes
        for (const bh of blackHoles) {
            const direction = new THREE.Vector3().subVectors(bh.position, this.position);
            const distanceSq = direction.lengthSq();
            if (distanceSq > bh.size * bh.size) { // Avoid division by zero and extreme forces inside black hole
                const forceMagnitude = G * this.mass * bh.mass / distanceSq;
                totalForce.add(direction.normalize().multiplyScalar(forceMagnitude));
            }
class Star {
    constructor(position, mass, size, color, note, instrumentIndex) {
        this.position = position.clone(); // Clone to avoid reference issues
        this.mass = mass;
        this.size = size;
        // Initial velocity (random direction, small magnitude)
        this.velocity = new THREE.Vector3(random(-1, 1), random(-1, 1), random(-1, 1)).normalize().multiplyScalar(1);
        this.color = color || mapColorToNote(note || random(musicScale)); // Map note to color
        this.note = note || random(musicScale);
        this.instrumentIndex = instrumentIndex !== undefined ? instrumentIndex : random([0, 1]);
        this.synth = instruments[this.instrumentIndex];

        // Tone.js oscillator for continuous tone when near black hole
        this.oscillator = new Tone.Oscillator(this.note, 'sine').start();
        this.oscillator.volume.value = -Infinity; // Start muted

        // Three.js visual mesh
        const geometry = new THREE.SphereGeometry(this.size, 16, 16);
        const material = new THREE.MeshBasicMaterial({
          color: new THREE.Color(this.color),
          emissive: new THREE.Color(this.color), // For pulsing effect
          emissiveIntensity: 0.1 // Base emissive intensity
        });
        this.mesh = new THREE.Mesh(geometry, material);
        this.mesh.position.copy(this.position);
        // scene.add(this.mesh); // Added in setup/mousePressed

        // Star Trail (using THREE.Points)
        const trailLength = 50; // Number of particles in the trail
        this.trailPositions = new Float32Array(trailLength * 3);
        this.trailColors = new Float32Array(trailLength * 3);
        const trailColor = new THREE.Color(this.color);

        for (let i = 0; i < trailLength; i++) {
            this.trailPositions[i * 3] = this.position.x;
            this.trailPositions[i * 3 + 1] = this.position.y;
            this.trailPositions[i * 3 + 2] = this.position.z;
            this.trailColors[i * 3] = trailColor.r;
            this.trailColors[i * 3 + 1] = trailColor.g;
            this.trailColors[i * 3 + 2] = trailColor.b;
        }

        this.trailGeometry = new THREE.BufferGeometry();
        this.trailGeometry.setAttribute('position', new THREE.BufferAttribute(this.trailPositions, 3));
        this.trailGeometry.setAttribute('color', new THREE.BufferAttribute(this.trailColors, 3));

        const trailMaterial = new THREE.PointsMaterial({
            size: this.size * 0.5, // Half the star's size
            sizeAttenuation: true,
            vertexColors: true,
            blending: THREE.AdditiveBlending,
            transparent: true,
            opacity: 0.8
        });
        this.trailMesh = new THREE.Points(this.trailGeometry, trailMaterial);
        // scene.add(this.trailMesh); // Added in setup/mousePressed

        this.playThreshold = 100; // Distance to black hole to trigger continuous tone

        // Pulse animation variables
        this.isPulsing = false;
        this.pulseStartTime = 0;
        this.pulseDuration = 30; // frames
        this.baseEmissiveIntensity = 0.1;
        this.pulseEmissiveIntensity = 1.0;
        this.baseScale = this.mesh.scale.x;
        this.pulseScale = this.baseScale * 1.2;
    }

    // Updates the star's position based on gravitational forces
    update(allStars, blackHoles) {
        let totalForce = new THREE.Vector3(0, 0, 0);

        // Gravity from other stars (N-body simulation)
        for (const otherStar of allStars) {
            if (otherStar !== this) {
                const direction = new THREE.Vector3().subVectors(otherStar.position, this.position);
                const distanceSq = direction.lengthSq();
                if (distanceSq > 100) { // Avoid extreme forces at very close distances
                    const forceMagnitude = G * this.mass * otherStar.mass / distanceSq;
                    totalForce.add(direction.normalize().multiplyScalar(forceMagnitude));
                }
            }
        }

        // Gravity from black holes
        for (const bh of blackHoles) {
            const direction = new THREE.Vector3().subVectors(bh.position, this.position);
            const distanceSq = direction.lengthSq();
            if (distanceSq > bh.size * bh.size) { // Avoid division by zero and extreme forces inside black hole
                const forceMagnitude = G * this.mass * bh.mass / distanceSq;
                totalForce.add(direction.normalize().multiplyScalar(forceMagnitude));
            }

            // Play continuous tone when near a black hole
            const distance = direction.length();
            if (distance < this.playThreshold) {
                this.oscillator.volume.value = -10; // Unmute
            } else {
                this.oscillator.volume.value = -Infinity; // Mute
            }
        }

        this.velocity.add(totalForce.divideScalar(this.mass));
        this.position.add(this.velocity);
        this.mesh.position.copy(this.position);

        // Update trail
        for (let i = this.trailPositions.length - 3; i >= 3; i -= 3) {
            this.trailPositions[i] = this.trailPositions[i - 3];
            this.trailPositions[i + 1] = this.trailPositions[i - 2];
            this.trailPositions[i + 2] = this.trailPositions[i - 1];

            // Fade trail colors
            this.trailColors[i] = this.trailColors[i - 3] * 0.98;
            this.trailColors[i + 1] = this.trailColors[i - 2] * 0.98;
            this.trailColors[i + 2] = this.trailColors[i - 1] * 0.98;
        }
        this.trailPositions[0] = this.position.x;
        this.trailPositions[1] = this.position.y;
        this.trailPositions[2] = this.position.z;

        const trailColor = new THREE.Color(this.color);
        this.trailColors[0] = trailColor.r;
        this.trailColors[1] = trailColor.g;
        this.trailColors[2] = trailColor.b;

        this.trailGeometry.attributes.position.needsUpdate = true;
        this.trailGeometry.attributes.color.needsUpdate = true;
    }

    // Updates the star's pulse animation
    updatePulse() {
        if (this.isPulsing) {
            const progress = (frameCount - this.pulseStartTime) / this.pulseDuration;
            if (progress < 1) {
                // Scale pulse
                const scaledProgress = sin(progress * PI); // Ease in-out with sine
                const currentScale = lerp(this.baseScale, this.pulseScale, scaledProgress);
                this.mesh.scale.set(currentScale, currentScale, currentScale);

                // Emissive intensity pulse
                const currentEmissiveIntensity = lerp(this.baseEmissiveIntensity, this.pulseEmissiveIntensity, scaledProgress);
                this.mesh.material.emissiveIntensity = currentEmissiveIntensity;
            } else {
                this.isPulsing = false;
                this.mesh.scale.set(this.baseScale, this.baseScale, this.baseScale); // Reset scale
                this.mesh.material.emissiveIntensity = this.baseEmissiveIntensity; // Reset emissive intensity
            }
        }
    }

    // Triggers the star's synth to play its note and starts visual pulse
    playNote() {
        this.synth.triggerAttackRelease(this.note, '8n');
        this.isPulsing = true;
        this.pulseStartTime = frameCount;
    }

    // Disposes of Three.js and Tone.js resources
    dispose() {
        this.oscillator.stop();
        this.oscillator.dispose();
        scene.remove(this.mesh);
        this.mesh.geometry.dispose();
        this.mesh.material.dispose();

        scene.remove(this.trailMesh);
        this.trailGeometry.dispose();
        this.trailMesh.material.dispose();
    }
}
Line-by-line explanation (19 lines)

🔧 Subcomponents:

initialization Constructor constructor(position, mass, size, color, note, instrumentIndex) { ... }

Initializes all star properties: physics (position, velocity, mass), visuals (mesh, color, trail), and sound (synth, oscillator)

method update() update(allStars, blackHoles) { ... }

Called every frame; calculates gravitational forces, updates position and velocity, and shifts the trail particles

method updatePulse() updatePulse() { ... }

Animates the star's scale and glow when it recently played a note

method playNote() playNote() { this.synth.triggerAttackRelease(this.note, '8n'); ... }

Triggers the synth to play the star's note and starts the pulse animation

method dispose() dispose() { ... }

Cleans up all Three.js and Tone.js resources before the star is removed

this.position = position.clone();
Clones the position vector to avoid external code changing this star's position accidentally
this.velocity = new THREE.Vector3(random(-1, 1), random(-1, 1), random(-1, 1)).normalize().multiplyScalar(1);
Gives each star a random initial velocity with magnitude 1 (slowly drifting in a random direction)
this.color = color || mapColorToNote(note || random(musicScale));
Uses the provided color if given; otherwise maps the note to a color using synesthesia
this.oscillator = new Tone.Oscillator(this.note, 'sine').start();
Creates a sine-wave oscillator at the star's note frequency but starts it muted
this.oscillator.volume.value = -Infinity;
Sets the oscillator to silent; it will unmute when the star gets close to the black hole
const geometry = new THREE.SphereGeometry(this.size, 16, 16);
Creates a sphere geometry with the star's specified size; 16 segments on each axis
emissive: new THREE.Color(this.color), emissiveIntensity: 0.1
Sets the star to glow; it will pulse brighter when playing a note
const trailLength = 50;
The trail will contain 50 particles, creating a comet-like effect
for (let i = 0; i < trailLength; i++) { this.trailPositions[i * 3] = this.position.x; ... }
Initializes the trail buffer with 50 copies of the star's starting position
this.trailGeometry.setAttribute('position', new THREE.BufferAttribute(this.trailPositions, 3));
Tells Three.js that trailPositions is the vertex position data; updates to this array update the trail
const forceMagnitude = G * this.mass * otherStar.mass / distanceSq;
Newton's law of gravitation: force is proportional to both masses and inversely proportional to distance squared
if (distanceSq > bh.size * bh.size) {
Only applies gravitational force if the star is outside the black hole's event horizon
if (distance < this.playThreshold) { this.oscillator.volume.value = -10; }
Unmutes the oscillator when the star is close to the black hole, creating a proximity-based drone sound
this.velocity.add(totalForce.divideScalar(this.mass));
Newton's second law: acceleration = force / mass; then acceleration is added to velocity
for (let i = this.trailPositions.length - 3; i >= 3; i -= 3) { this.trailPositions[i] = this.trailPositions[i - 3]; ... }
Shifts all trail particles backward; the oldest particle disappears and the newest takes its place
this.trailColors[i] = this.trailColors[i - 3] * 0.98;
Fades the trail by multiplying color intensity by 0.98 each frame, making old particles more transparent
const scaledProgress = sin(progress * PI);
Uses a sine wave to create smooth ease-in-out animation: starts slow, peaks, then slows again
const currentScale = lerp(this.baseScale, this.pulseScale, scaledProgress);
Linearly interpolates between the base and pulse scale based on the animation progress
this.synth.triggerAttackRelease(this.note, '8n');
Plays a musical note for 8n duration (an eighth note at the current tempo)

class BlackHole

The BlackHole class demonstrates advanced Three.js techniques: procedural canvas textures, ring geometry, additive blending, and animated texture offsets. It also shows how to use Tone.js's Transport for musically-timed events. The accretion disk is visually striking and serves as the gravitational anchor of the system.

class BlackHole {
    constructor(position, mass, size) {
        this.position = position.clone();
        this.mass = mass;
        this.size = size;
        this.color = '#000000'; // Black
        this.pulseColor = '#FF0000'; // Red pulse for visual effect

        // Three.js visual mesh (a dark sphere)
        const geometry = new THREE.SphereGeometry(this.size, 32, 32);
        const material = new THREE.MeshBasicMaterial({ color: new THREE.Color(this.color) });
        this.mesh = new THREE.Mesh(geometry, material);
        this.mesh.position.copy(this.position);
        // scene.add(this.mesh); // Added in setup

        // Accretion Disk (RingGeometry with CanvasTexture)
        const diskInnerRadius = this.size * 1.5;
        const diskOuterRadius = this.size * 5;
        const diskGeometry = new THREE.RingGeometry(diskInnerRadius, diskOuterRadius, 64);

        // Create a canvas texture for the disk
        const canvas = document.createElement('canvas');
        canvas.width = 128;
        canvas.height = 128;
        const ctx = canvas.getContext('2d');

        // Draw a swirling gradient
        const gradient = ctx.createRadialGradient(64, 64, diskInnerRadius/diskOuterRadius * 64, 64, 64, 64);
        gradient.addColorStop(0, 'rgba(255, 0, 0, 0.8)');   // Red hot center
        gradient.addColorStop(0.3, 'rgba(255, 127, 0, 0.6)'); // Orange
        gradient.addColorStop(0.6, 'rgba(255, 255, 0, 0.4)'); // Yellow
        gradient.addColorStop(0.8, 'rgba(100, 100, 100, 0.2)'); // Grayish
        gradient.addColorStop(1, 'rgba(0, 0, 0, 0)');      // Transparent black outer edge

        ctx.fillStyle = gradient;
        ctx.fillRect(0, 0, 128, 128);

        const diskTexture = new THREE.CanvasTexture(canvas);
        diskTexture.wrapS = diskTexture.wrapT = THREE.RepeatWrapping; // For swirling effect
        diskTexture.offset.set(0, 0);
        diskTexture.repeat.set(1, 1);

        const diskMaterial = new THREE.MeshBasicMaterial({
            map: diskTexture,
            side: THREE.DoubleSide,
            transparent: true,
            blending: THREE.AdditiveBlending,
            opacity: 0.7
        });
        this.accretionDiskMesh = new THREE.Mesh(diskGeometry, diskMaterial);
        this.accretionDiskMesh.position.copy(this.position);
        this.accretionDiskMesh.rotation.x = -Math.PI / 2; // Orient horizontally
        // scene.add(this.accretionDiskMesh); // Added in setup

        // Tone.js bass synth for the pulsing sound
        this.bassSynth = new Tone.MembraneSynth({
            pitchDecay: 0.008,
            octaves: 2,
            envelope: { attack: 0.001, decay: 0.5, sustain: 0.01, release: 0.8 },
            portamento: 0
        }).connect(masterVolume);

        // Schedule bass notes to play periodically
        Tone.Transport.scheduleRepeat(() => {
            this.bassSynth.triggerAttackRelease('C1', '4n'); // Low C note
            // Visual pulse effect
            this.mesh.material.color.set(this.pulseColor); // Change color to pulse
            setTimeout(() => this.mesh.material.color.set(this.color), 100); // Revert after 100ms
        }, '1m'); // Repeat every measure (1m = 4 beats at 120bpm)
        Tone.Transport.start(); // Start the Tone.js transport for scheduling
    }

    update() {
        // Black holes are static in this simulation
        // Rotate the accretion disk for swirling effect
        this.accretionDiskMesh.rotation.z += 0.01;
        this.accretionDiskMesh.material.map.offset.y += 0.005; // Scroll texture
        this.accretionDiskMesh.material.map.needsUpdate = true;
    }

    dispose() {
        this.bassSynth.dispose();
        Tone.Transport.stop();
        scene.remove(this.mesh);
        this.mesh.geometry.dispose();
        this.mesh.material.dispose();

        scene.remove(this.accretionDiskMesh);
        this.accretionDiskMesh.geometry.dispose();
        this.accretionDiskMesh.material.dispose();
    }
}
Line-by-line explanation (12 lines)

🔧 Subcomponents:

initialization Constructor constructor(position, mass, size) { ... }

Creates a black hole with a dark sphere mesh, rotating accretion disk with gradient texture, and periodic bass sound

initialization Accretion Disk Creation const diskInnerRadius = this.size * 1.5; const diskOuterRadius = this.size * 5; ...

Builds a rotating disk with a procedurally-drawn canvas gradient texture

method update() update() { this.accretionDiskMesh.rotation.z += 0.01; ... }

Rotates the disk and scrolls its texture each frame for the illusion of swirling motion

this.position = position.clone();
Clones the position to avoid reference issues
const geometry = new THREE.SphereGeometry(this.size, 32, 32);
Creates a sphere; the black hole mesh is simple because it's mostly obscured by the disk
const diskInnerRadius = this.size * 1.5;
The inner edge of the accretion disk starts just outside the black hole's event horizon
const diskOuterRadius = this.size * 5;
The disk extends outward to 5 times the black hole radius, creating a wide, visible ring
const canvas = document.createElement('canvas');
Creates an HTML5 canvas element (in memory, not on the page) to draw the disk texture
const gradient = ctx.createRadialGradient(...);
Creates a radial gradient that fades from hot red at the center to transparent black at the edges
const diskTexture = new THREE.CanvasTexture(canvas);
Converts the canvas drawing into a Three.js texture that can be applied to the disk mesh
this.accretionDiskMesh.rotation.x = -Math.PI / 2;
Rotates the disk 90 degrees so it's horizontal (perpendicular to the y-axis) instead of vertical
this.bassSynth.triggerAttackRelease('C1', '4n');
Plays a very low C note (C1) for a quarter note duration; C1 is 16.35 Hz, deeply subsonic
Tone.Transport.scheduleRepeat(() => { ... }, '1m');
Uses Tone.js's transport (a musical clock) to repeat the bass note every measure, syncing with the tempo
this.accretionDiskMesh.rotation.z += 0.01;
Increments the disk's rotation each frame, creating the swirling visual effect
this.accretionDiskMesh.material.map.offset.y += 0.005;
Scrolls the texture upward slightly, adding to the illusion of motion

class ShootingStar

The ShootingStar class is simpler than Star because it moves in a straight line (no physics) but uses the same trail-rendering technique. It demonstrates how to manually manage lifespans and perform cleanup when objects reach the end of their usefulness. The fade-out effect (opacity mapped to life) is a nice UX pattern that makes objects disappear smoothly rather than pop.

🔬 This loop pre-fills the shooting star's trail by working backward from its start position. What happens if you change 'i * 0.5' to 'i * 2'? Will the pre-trail be longer or shorter?

        // Initialize with a few trail particles
        for (let i = 0; i < trailLength; i++) {
            const p = new THREE.Vector3().subVectors(this.position, this.velocity.clone().multiplyScalar(i * 0.5));
            positions[i * 3] = p.x;
            positions[i * 3 + 1] = p.y;
            positions[i * 3 + 2] = p.z;
class ShootingStar {
    constructor(startPosition, velocity) {
        this.position = startPosition.clone();
        this.velocity = velocity.clone();
        this.life = 180; // frames (approx 3 seconds - increased for longer trail)
        this.color = '#FFFFFF'; // Bright white/yellow
        this.size = 5; // Size of individual particles in the trail (increased)

        // Three.js visual (using THREE.Points for a trail effect)
        const trailLength = 30; // Number of particles in the trail
        const geometry = new THREE.BufferGeometry();
        const positions = new Float32Array(trailLength * 3);
        const colors = new Float32Array(trailLength * 3);
        const color = new THREE.Color(this.color);

        // Initialize with a few trail particles
        for (let i = 0; i < trailLength; i++) {
            const p = new THREE.Vector3().subVectors(this.position, this.velocity.clone().multiplyScalar(i * 0.5));
            positions[i * 3] = p.x;
            positions[i * 3 + 1] = p.y;
            positions[i * 3 + 2] = p.z;
            colors[i * 3] = color.r;
            colors[i * 3 + 1] = color.g;
            colors[i * 3 + 2] = color.b;
        }

        geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
        geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

        const material = new THREE.PointsMaterial({
            size: this.size,
            sizeAttenuation: true,
            vertexColors: true,
            blending: THREE.AdditiveBlending,
            transparent: true,
            opacity: 1.0 // Start fully opaque
        });

        this.mesh = new THREE.Points(geometry, material);
        scene.add(this.mesh);

        // Tone.js sound (quick, high note)
        this.note = random(['G5', 'A5', 'C6']);
        this.synth = instruments[0]; // Use the first synth for shooting stars
    }

    update() {
        this.position.add(this.velocity);
        this.life--;

        // Update trail particles
        const positions = this.mesh.geometry.attributes.position.array;
        const colors = this.mesh.geometry.attributes.color.array;

        // Shift existing particles back, add new at front
        for (let i = positions.length - 3; i >= 3; i -= 3) {
            positions[i] = positions[i - 3];
            positions[i + 1] = positions[i - 2];
            positions[i + 2] = positions[i - 1];

            // Fade out older particles by reducing their color intensity
            colors[i] = colors[i - 3] * 0.95;
            colors[i + 1] = colors[i - 2] * 0.95;
            colors[i + 2] = colors[i - 1] * 0.95;
        }
        positions[0] = this.position.x;
        positions[1] = this.position.y;
        positions[2] = this.position.z;

        // New particle at front is full color
        colors[0] = 1;
        colors[1] = 1;
        colors[2] = 1;

        // Fade the entire shooting star mesh as it nears its end of life
        this.mesh.material.opacity = map(this.life, 0, 180, 0, 1);

        this.mesh.geometry.attributes.position.needsUpdate = true;
        this.mesh.geometry.attributes.color.needsUpdate = true;
    }

    playNote() {
        this.synth.triggerAttackRelease(this.note, '16n');
    }

    isFinished() {
        return this.life <= 0;
    }

    dispose() {
        scene.remove(this.mesh);
        this.mesh.geometry.dispose();
        this.mesh.material.dispose();
    }
}
Line-by-line explanation (10 lines)

🔧 Subcomponents:

initialization Constructor constructor(startPosition, velocity) { ... }

Creates a shooting star with an initial position and velocity, and builds its particle trail

method update() update() { ... }

Advances the shooting star's position, shifts trail particles, and fades the entire effect

variable Lifespan Management this.life = 180; ... this.life--;

Counts down from 180 frames; when life reaches 0, the shooting star is removed

this.position = startPosition.clone();
Clones the start position to avoid reference issues
this.velocity = velocity.clone();
Clones the velocity vector so changes to it don't affect the original
this.life = 180;
Each shooting star lasts 180 frames; at 60fps, that's 3 seconds
for (let i = 0; i < trailLength; i++) { const p = new THREE.Vector3().subVectors(this.position, this.velocity.clone().multiplyScalar(i * 0.5)); ... }
Pre-fills the trail by calculating positions backward from the start, creating the illusion the star has been moving
this.position.add(this.velocity);
Moves the shooting star forward by its velocity each frame (linear motion, no gravity)
this.life--;
Decrements the lifespan; when it reaches 0, isFinished() will return true and the star will be removed
for (let i = positions.length - 3; i >= 3; i -= 3) { positions[i] = positions[i - 3]; ... }
Shifts all trail particles backward; the oldest particle disappears and a new one is added at the front
colors[i] = colors[i - 3] * 0.95;
Fades older particles by reducing their color brightness
this.mesh.material.opacity = map(this.life, 0, 180, 0, 1);
As life counts down from 180 to 0, opacity fades from 1 to 0, making the entire shooting star fade away
this.synth.triggerAttackRelease(this.note, '16n');
Plays a high note for 16th note duration (very quick and punchy)

mapColorToNote()

This function implements synesthesia—a perceptual blending of senses where musical notes are associated with colors. The mapping roughly follows the visible light spectrum (red → violet) as note pitch increases (C → B), creating an intuitive visual language for the composition. This is a simple but powerful way to make abstract music visible.

function mapColorToNote(note) {
    const noteName = note.charAt(0); // Get the letter of the note (C, D, E, etc.)
    switch (noteName) {
        case 'C': return '#FF0000'; // Red
        case 'D': return '#FF7F00'; // Orange
        case 'E': return '#FFFF00'; // Yellow
        case 'F': return '#00FF00'; // Green (F is not in pentatonic, but included for completeness)
        case 'G': return '#0000FF'; // Blue
        case 'A': return '#4B0082'; // Indigo
        case 'B': return '#EE82EE'; // Violet (B is not in pentatonic, but included for completeness)
        default: return '#FFFFFF'; // White for any other notes
    }
}
Line-by-line explanation (4 lines)

🔧 Subcomponents:

calculation Extract Note Letter const noteName = note.charAt(0);

Gets the first character of the note string (C, D, E, etc.), ignoring the octave number

switch-case Map to Color switch (noteName) { case 'C': return '#FF0000'; ... }

Maps each note letter to a synesthesia-inspired color using the visible light spectrum

const noteName = note.charAt(0);
Extracts the first letter of the note (e.g., 'C' from 'C4'), ignoring the octave number
case 'C': return '#FF0000'; // Red
Maps C to red, the lowest visible light frequency (also lowest in music psychologically)
case 'G': return '#0000FF'; // Blue
Maps G to blue, a mid-high frequency (in both light and music)
case 'A': return '#4B0082'; // Indigo
Maps A to indigo, a high frequency in both domains

📦 Key Variables

G number

Gravitational constant scaled for the simulation; controls how strongly objects attract each other

const G = 0.05;
stars array

Holds all active Star objects in the scene; iterated every frame for physics updates and rendering

let stars = [];
blackHoles array

Holds all BlackHole objects; typically just one central black hole, but the system supports multiple

let blackHoles = [];
shootingStars array

Holds active ShootingStar objects created by mouse dragging; auto-cleaned when life reaches 0

let shootingStars = [];
musicScale array

Array of note names in the C Major Pentatonic scale across three octaves; stars pick notes from this array

let musicScale = ['C4', 'D4', 'E4', 'G4', 'A4', 'C5', 'D5', 'E5', 'G5', 'A5', 'C6', 'D6', 'E6', 'G6', 'A6'];
instruments array

Array of Tone.js PolySynth and MembraneSynth objects; each star is assigned one to determine its timbre

let instruments = [];
masterVolume object

Tone.js Volume control connected to the output; controls overall loudness of all synthesizers

let masterVolume;
aiSuggestion object

Stores the current AI suggestion (note, size, color, instrument, position) or null if none active

let aiSuggestion = null;
aiSuggestionMesh object

Three.js mesh object representing the AI suggestion visually as a semi-transparent sphere

let aiSuggestionMesh = null;
aiSuggestionCooldown number

Frame counter that prevents AI suggestions from appearing too frequently; decrements each frame

let aiSuggestionCooldown = 0;
nebulaBackgroundMesh object

Three.js mesh with shader material that renders the animated nebula background

let nebulaBackgroundMesh;
scene object

Three.js Scene object containing all 3D objects (stars, black hole, nebula) to be rendered

let scene, camera, renderer, controls;
camera object

Three.js PerspectiveCamera controlling the viewpoint; OrbitControls allow interactive rotation

let scene, camera, renderer, controls;
renderer object

Three.js WebGLRenderer that draws the scene to the canvas each frame

let scene, camera, renderer, controls;
controls object

Three.js OrbitControls for interactive camera movement (rotate, zoom, pan with mouse)

let scene, camera, renderer, controls;
p5Canvas object

The hidden p5.js canvas used only to capture mouse input; Three.js provides the visible canvas

let p5Canvas;

🔧 Potential Improvements (6)

Here are some ways this code could be enhanced:

PERFORMANCE Star.update()

N-body gravity calculation is O(n²)—with many stars, this becomes slow. Each star checks every other star and black hole.

💡 Implement spatial partitioning (quadtree/octree) to only calculate gravity between nearby objects, or use a GPU-based physics library for large-scale simulations.

BUG mousePressed() and mouseDragged()

Raycasting converts mouse coordinates to normalized space but doesn't account for viewport transformations if the canvas is resized irregularly or if the page has scrolling.

💡 Use renderer.domElement.getBoundingClientRect() to get the canvas position on the page and adjust mouse coordinates accordingly.

STYLE suggestNewCelestialBody()

The three rules for AI suggestions are hardcoded with magic numbers (e.g., < 1, < 2) making it hard to tweak the AI's behavior without editing code.

💡 Define rule thresholds as named constants (e.g., const BASS_COUNT_THRESHOLD = 1) at the top of the function, or in a configuration object.

BUG draw() and drawThreeJS()

The p5.js draw() function and Three.js requestAnimationFrame() run independently, potentially at different frame rates, causing sync issues.

💡 Consolidate all animation logic into drawThreeJS() and remove the p5.js draw() function, or ensure they're tightly synchronized.

FEATURE Tone.Transport scheduling

Tone.Transport.start() is called every time a black hole is created, potentially starting it multiple times and causing duplicate notes.

💡 Check if Transport is already running before starting it, or initialize it once in setup(): if (!Tone.Transport.started) Tone.Transport.start();

PERFORMANCE Star and ShootingStar trails

Every frame, the trail positions and colors arrays are updated and needsUpdate is set to true, causing the GPU to recompute the entire buffer. With many stars, this is expensive.

💡 Use a circular buffer to update only the changed particles, or use instance rendering to draw all trails in a single GPU call.

🔄 Code Flow

Code flow showing setup, draw, drawthreejs, mousepressed, mousedragged, analyzecosmiccomposition, suggestnewcelestialb, displayaisuggestion, createsuggestedbody, star, blackhole, shootingstar, mapcolortonote

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

graph TD start[Start] --> setup[setup] setup --> draw[draw loop] draw --> cooldown_decrement[cooldown-decrement] draw --> suggestion_trigger[suggestion-trigger] draw --> star_update_loop[star-update-loop] draw --> blackhole_update_loop[blackhole-update-loop] draw --> shooting_star_update[shooting-star-update] draw --> cleanup_shooting_stars[cleanup-shooting-stars] draw --> nebula_animate[nebula-animate] click setup href "#fn-setup" click draw href "#fn-draw" click cooldown_decrement href "#sub-cooldown-decrement" click suggestion_trigger href "#sub-suggestion-trigger" click star_update_loop href "#sub-star-update-loop" click blackhole_update_loop href "#sub-blackhole-update-loop" click shooting_star_update href "#sub-shooting-star-update" click cleanup_shooting_stars href "#sub-cleanup-shooting-stars" click nebula_animate href "#sub-nebula-animate" setup --> p5_setup[p5-setup] setup --> three_scene[three-scene] setup --> tone_synths[tone-synths] setup --> initial_stars[initial-stars] setup --> nebula_shader[nebula-shader] click p5_setup href "#sub-p5-setup" click three_scene href "#sub-three-scene" click tone_synths href "#sub-tone-synths" click initial_stars href "#sub-initial-stars" click nebula_shader href "#sub-nebula-shader" cooldown_decrement --> cooldown_decrement_logic[Cooldown Timer Logic] suggestion_trigger --> suggestion_trigger_logic[AI Suggestion Logic] click cooldown_decrement_logic href "#sub-cooldown-decrement" click suggestion_trigger_logic href "#sub-suggestion-trigger" suggestion_trigger_logic --> suggestion_click_check[suggestion-click-check] suggestion_trigger_logic --> suggestion_creation[suggestion-creation] click suggestion_click_check href "#sub-suggestion-click-check" click suggestion_creation href "#sub-suggestion-creation" star_update_loop --> star_update[star-update] star_update_loop --> star_updatepulse[star-updatepulse] click star_update href "#sub-star-update" click star_updatepulse href "#sub-star-updatepulse" blackhole_update_loop --> bh_update[bh-update] click bh_update href "#sub-bh-update" shooting_star_update --> ss_update[ss-update] click ss_update href "#sub-ss-update" cleanup_shooting_stars --> cleanup_logic[Cleanup Logic] click cleanup_logic href "#sub-cleanup-logic"

❓ Frequently Asked Questions

What visual elements does the Sketch 2025-12-12 01:03 create?

This sketch features a dynamic cosmic scene filled with stars, black holes, and a nebula background, enhanced by animated visual effects.

How can users interact with the Sketch 2025-12-12 01:03?

Users can interact with the sketch through mouse input, which influences the cosmic elements and may trigger AI suggestions within the simulation.

What creative coding techniques are showcased in the Sketch 2025-12-12 01:03?

The sketch demonstrates advanced techniques such as shader programming for the nebula background, real-time physics simulation, and audio synthesis using the Tone.js library.

Preview

Sketch 2025-12-12 01:03 - p5.js creative coding sketch preview
Sketch Preview
Code flow diagram showing the structure of Sketch 2025-12-12 01:03 - Code flow showing setup, draw, drawthreejs, mousepressed, mousedragged, analyzecosmiccomposition, suggestnewcelestialb, displayaisuggestion, createsuggestedbody, star, blackhole, shootingstar, mapcolortonote
Code Flow Diagram