Learn Creative Coding (#15) - Mini-Project: Particle Galaxy

in StemSocial10 days ago

Learn Creative Coding (#15) - Mini-Project: Particle Galaxy

cc-banner

Every few episodes we stop learning new tools and start using them. This is that episode. We're building an interactive particle galaxy -- a swirling cosmic simulation that pulls together particle systems from episode 11, trig from episode 13, noise from episode 12, and Bezier flow from episode 14. Everything working together in one piece.

Mini-projects are where creative coding gets real. Individual techniques are building blocks. Combining them is where the art happens. By the end of this episode you'll have a living, breathing, mouse-interactive galaxy that you built from scratch. No libraries beyond p5, no copy-paste -- just the tools we've earned over the last five episodes.

I always find mini-project episodes the most satisfying to write, because it's where all the abstract stuff clicks into something visual and immediate. Our first mini-project (the generative poster in episode 8) combined Phase 1 concepts. This one does the same for Phase 2 -- but we've got way more powerful tools now. Let's go :-)

The design philosophy

One thing before we write a single line of code. The best creative coding galaxies don't try to be astronomically accurate. They aim for emotionally accurate -- they should feel cosmic. That means deep dark backgrounds, tiny bright points with the occasional larger glow, color gradients that evoke nebulae, and most importantly: movement that suggests vast scale and slow cosmic time.

Even though our canvas is 600 pixels wide, if the particles drift slowly and the colors are right, the viewer's brain fills in "space." A galaxy is mostly void. The darkness is as important as the stars. Start dark, add light -- not the other way around. That's the creative coding mindset we've been building: you're not rendering reality, you're triggering associations. A handful of dots, colored and moving just right, becomes a universe.

Allez, let's build it.

Setting up the canvas

No p5.js this time. Raw vanilla Canvas, because we've been working without training wheels since episode 9 and it's paying off. Full screen, black background, no scrollbars:

<!DOCTYPE html>
<html>
<head>
  <style>
    body { margin: 0; background: #000; overflow: hidden; }
    canvas { display: block; }
  </style>
</head>
<body>
  <canvas id="galaxy"></canvas>
  <script src="galaxy.js"></script>
</body>
</html>
// galaxy.js
const canvas = document.getElementById('galaxy');
const ctx = canvas.getContext('2d');

canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const centerX = canvas.width / 2;
const centerY = canvas.height / 2;

Nothing fancy. We've done this setup a dozen times now. The center coordinates are important though -- everything in our galaxy orbits around this point.

The Star class

Each star needs: an angle (its position on its orbit), a distance from center, an angular speed, a size, a color, and some noise offset for organic movement. If the particle class from episode 11 was the simplest useful particle, this Star class is its specialized cousin -- same concept, different initial conditions:

class Star {
  constructor() {
    this.angle = Math.random() * Math.PI * 2;
    this.distance = Math.random() * Math.min(centerX, centerY) * 0.9;
    this.baseDistance = this.distance;

    // further stars orbit slower - like real galaxies
    this.speed = (0.0005 + Math.random() * 0.002) *
      (1 - this.distance / (centerX * 0.9));

    // noise offset so each star wobbles differently
    this.noiseOffsetX = Math.random() * 1000;
    this.noiseOffsetY = Math.random() * 1000;

    // visual properties
    this.size = Math.random() * 2.5 + 0.5;
    this.brightness = Math.random();

    // color: inner stars warmer, outer stars cooler
    let ratio = this.distance / (centerX * 0.9);
    if (ratio < 0.3) {
      // core: warm yellow/white
      this.r = 255;
      this.g = 220 + Math.random() * 35;
      this.b = 180 + Math.random() * 50;
    } else if (ratio < 0.6) {
      // mid: blue-white
      this.r = 180 + Math.random() * 75;
      this.g = 200 + Math.random() * 55;
      this.b = 255;
    } else {
      // outer: deep blue/purple
      this.r = 120 + Math.random() * 80;
      this.g = 130 + Math.random() * 70;
      this.b = 200 + Math.random() * 55;
    }
  }
}

See the speed calculation? Stars closer to the center orbit faster. Real galaxies don't exactly work this way (look up "galaxy rotation curves" for a fun rabbit hole about dark matter), but it looks convincing and it creates that visual depth where the core swirls fast and the arms trail behind lazily.

The color scheme uses a distance ratio. Inner third gets warm yellow-whites (like the dense stellar core of a real galaxy). Middle band gets blue-whites. Outer rim gets deep blues and purples. This gradient is subtle but it gives the galaxy that sense of heat at the center fading to cold emptiness at the edges. Remember episode 7 where we talked about color conveying temperature? Same principle, different context.

Simple noise for wobble

We built Perlin noise from scratch in episode 12, but for this project we don't need the full implementation. A compact value noise function is good enough for organic wobble -- we just need something that's smooth and continuous:

// simple value noise - not true Perlin, but works for wobble
function simpleNoise(x, y) {
  let n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
  return n - Math.floor(n);
}

function smoothNoise(x, y) {
  let ix = Math.floor(x);
  let iy = Math.floor(y);
  let fx = x - ix;
  let fy = y - iy;

  // smoothstep interpolation
  fx = fx * fx * (3 - 2 * fx);
  fy = fy * fy * (3 - 2 * fy);

  let a = simpleNoise(ix, iy);
  let b = simpleNoise(ix + 1, iy);
  let c = simpleNoise(ix, iy + 1);
  let d = simpleNoise(ix + 1, iy + 1);

  return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
}

That simpleNoise function is a trick from shader programming -- hash a 2D coordinate into a pseudo-random value using big irrational-ish constants. It's not cryptographically random, but it's random enough for visual wobble. The smoothNoise wrapper does bilinear interpolation with a smoothstep fade -- same concept as the fade function we built in our Perlin implementation (episode 12), just simpler. The 3t^2 - 2t^3 smoothstep is the original Perlin fade curve from 1983. We used the improved 6t^5 - 15t^4 + 10t^3 version last time, but for wobble on star orbits the difference is invisible.

Updating and drawing stars

Here's where the trig from episode 13 comes in. Each star lives in polar coordinates -- an angle and a radius. Every frame we advance the angle (orbital rotation), add noise wobble to the radius, convert to cartesian for drawing, and optionally pull toward the mouse:

Star.prototype.update = function(time, mouseX, mouseY) {
  // orbital rotation
  this.angle += this.speed;

  // noise wobble on the radius
  let wobble = smoothNoise(
    this.noiseOffsetX + time * 0.0003,
    this.noiseOffsetY + time * 0.0003
  ) * 30 - 15;

  this.distance = this.baseDistance + wobble;

  // polar to cartesian - THE formula from episode 13
  this.x = centerX + Math.cos(this.angle) * this.distance;
  this.y = centerY + Math.sin(this.angle) * this.distance;

  // mouse gravity influence
  if (mouseX !== null) {
    let dx = mouseX - this.x;
    let dy = mouseY - this.y;
    let dist = Math.sqrt(dx * dx + dy * dy);

    if (dist < 200 && dist > 1) {
      let force = (200 - dist) / 200;
      force = force * force * 8; // quadratic falloff
      this.x += (dx / dist) * force;
      this.y += (dy / dist) * force;
    }
  }

  // twinkle
  this.alpha = 0.3 + this.brightness * 0.7 *
    (0.7 + 0.3 * Math.sin(time * 0.003 + this.noiseOffsetX));
};

Star.prototype.draw = function() {
  ctx.beginPath();
  ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
  ctx.fillStyle = `rgba(${this.r|0}, ${this.g|0}, ${this.b|0}, ${this.alpha})`;
  ctx.fill();
};

There's a lot happening here, so let me break it down.

The orbital rotation is just this.angle += this.speed. That's it. Each frame the star advances by a tiny angle increment. Simple, but 3000 of these at different speeds creates complex visual motion.

The noise wobble samples our smooth noise function at coordinates that drift slowly over time (time * 0.0003). Each star has unique offsets (noiseOffsetX, noiseOffsetY) so they all wobble independently. The result gets mapped to a range of -15 to +15 pixels, which nudges the orbital radius. Without this, stars would trace perfect circles. With it, their orbits breathe and shift organically -- exactly the kind of noise-driven displacement we explored in episode 12.

The polar to cartesian conversion -- cos(angle) * distance for x, sin(angle) * distance for y -- is the same formula from episode 13 that powered our spirals, roses, and phyllotaxis patterns. It's the most important two lines in creative coding and I hope it's muscle memory by now :-)

The mouse gravity uses the attraction model from episode 11's particle systems. Calculate direction and distance to the mouse, apply a force that weakens with distance. The quadratic falloff (force * force) makes the effect feel natural -- strong up close, almost nothing at the edges of influence. Stars near your cursor bend toward it like comets approaching a gravitational well.

The twinkle uses a sin wave with each star's unique noise offset as a phase offset. Remember episode 13: phase offsets turn synchronous motion into organic waves. Without the offset, all stars would pulse in unison. With it, each star twinkles at its own rhythm.

The |0 trick on the color values floors them to integers. Template literal color strings need integer RGB values, and bitwise OR with zero is faster than Math.floor() -- same optimization trick we used for pixel indexing in episode 10.

Spiral arms

A galaxy without spiral arms is just a round blob. We need to bias star positions toward logarithmic spiral curves. The trick: don't put stars ON the arm (that looks too rigid). Put them NEAR the arm, with spread that increases with distance:

function createGalaxy(count) {
  let stars = [];
  let arms = 3;     // number of spiral arms
  let twist = 3;    // how tightly the arms wind

  for (let i = 0; i < count; i++) {
    let star = new Star();

    // 60% of stars get pulled toward a spiral arm
    if (Math.random() < 0.6) {
      let arm = Math.floor(Math.random() * arms);
      let armAngle = (arm / arms) * Math.PI * 2;
      let spiralAngle = armAngle +
        (star.distance / (centerX * 0.9)) * twist;

      // pull toward spiral - not exactly on it, just biased
      let spread = 0.3 + (star.distance / (centerX * 0.9)) * 0.5;
      star.angle = spiralAngle + (Math.random() - 0.5) * spread;
    }

    stars.push(star);
  }

  return stars;
}

The spiral equation here is an Archimedean spiral: the angle increases linearly with distance. The twist parameter controls how tightly wound the arms are. Three arms at twist=3 gives a nice balance between visible structure and organic spread.

The spread variable is the key creative detail. At the center (distance=0), spread is 0.3 radians -- stars cluster tightly around the arm. At the outer edge (distance=max), spread is 0.8 radians -- the arms fan out wide. This is what real spirals do. If you make the spread constant, the arms look like rigid lines. If you let it grow with distance, they look like they're dissolving into the void. Nature figured this out; we're just copying :-)

The 60/40 split means most stars follow the arms, but 40% are scattered randomly. That background noise is important -- without it the galaxy looks too structured, too designed. The random stars fill in the voids between arms and make the whole thing feel three-dimensional.

The core glow

Every galaxy has a bright center. We'll fake it with a radial gradient drawn before the stars:

function drawCore() {
  let gradient = ctx.createRadialGradient(
    centerX, centerY, 0,
    centerX, centerY, 120
  );
  gradient.addColorStop(0, 'rgba(255, 240, 200, 0.15)');
  gradient.addColorStop(0.3, 'rgba(255, 220, 180, 0.08)');
  gradient.addColorStop(1, 'rgba(255, 200, 150, 0)');

  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
}

Subtle. But it makes a real difference -- without it the center looks empty and cold. With it, there's warmth radiating outward. The alpha values are deliberately low (0.15 at peak). If you make the glow too strong it overpowers the individual stars and the galaxy looks like a blurry blob instead of a field of points. Less is more here.

This is the same createRadialGradient we first used back in earlier episodes, but notice how the color stops are designed to match the star color scheme -- warm yellows and oranges fading to nothing. Consistency between the glow and the star colors sells the illusion.

The animation loop

Now we wire everything together. The animation loop clears the canvas with a semi-transparent black (for trailing), draws the core, then updates and draws every star:

let mouseX = null;
let mouseY = null;

canvas.addEventListener('mousemove', (e) => {
  mouseX = e.clientX;
  mouseY = e.clientY;
});

canvas.addEventListener('mouseleave', () => {
  mouseX = null;
  mouseY = null;
});

const stars = createGalaxy(3000);

function animate(time) {
  // semi-transparent black for trails
  ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  drawCore();

  for (let star of stars) {
    star.update(time, mouseX, mouseY);
    star.draw();
  }

  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

That rgba(0, 0, 0, 0.08) instead of a solid black clear? That's what creates the trailing effect. Instead of erasing the previous frame completely, we paint a nearly-transparent black rectangle over everything. Stars leave faint traces as they orbit. It makes the whole thing feel alive -- like a long-exposure photograph of the night sky.

We used this exact same technique for particle trails in episode 11. The alpha value controls how long the trails persist. Lower alpha = longer trails = dreamier look. At 0.08 you get trails that last maybe 15-20 frames, which is just right for orbital motion. Try 0.03 for really long ghostly trails, or 0.15 for shorter, snappier motion.

Setting mouseX/mouseY to null when the mouse leaves the canvas means the gravity effect disappears when you stop interacting. This is a small but important detail -- it lets the galaxy return to its natural state. The stars drift back to their noise-wobbled orbits, undisturbed. When you bring the cursor back, the gravitational pull resumes smoothly.

Nebula dust layer

A galaxy isn't just stars. There's gas and dust between the arms -- faint clouds of color that give the whole composition depth. We can add these as large, slow, translucent particles that don't interact with the mouse:

class DustCloud {
  constructor() {
    this.angle = Math.random() * Math.PI * 2;
    this.distance = 50 + Math.random() * Math.min(centerX, centerY) * 0.7;
    this.speed = 0.0001 + Math.random() * 0.0003;
    this.size = 30 + Math.random() * 60;
    this.noiseOffset = Math.random() * 1000;

    // nebula colors: pinks, blues, purples
    let palette = Math.random();
    if (palette < 0.33) {
      this.r = 80 + Math.random() * 40;
      this.g = 40 + Math.random() * 30;
      this.b = 120 + Math.random() * 60;
    } else if (palette < 0.66) {
      this.r = 40 + Math.random() * 30;
      this.g = 60 + Math.random() * 40;
      this.b = 130 + Math.random() * 60;
    } else {
      this.r = 100 + Math.random() * 40;
      this.g = 30 + Math.random() * 20;
      this.b = 80 + Math.random() * 40;
    }
  }

  update(time) {
    this.angle += this.speed;
    let wobble = smoothNoise(
      this.noiseOffset + time * 0.0001,
      this.noiseOffset * 0.7 + time * 0.0001
    ) * 20 - 10;
    this.x = centerX + Math.cos(this.angle) * (this.distance + wobble);
    this.y = centerY + Math.sin(this.angle) * (this.distance + wobble);
  }

  draw() {
    let gradient = ctx.createRadialGradient(
      this.x, this.y, 0,
      this.x, this.y, this.size
    );
    gradient.addColorStop(0, `rgba(${this.r|0}, ${this.g|0}, ${this.b|0}, 0.03)`);
    gradient.addColorStop(1, `rgba(${this.r|0}, ${this.g|0}, ${this.b|0}, 0)`);
    ctx.fillStyle = gradient;
    ctx.fillRect(
      this.x - this.size, this.y - this.size,
      this.size * 2, this.size * 2
    );
  }
}

The dust clouds orbit much slower than stars (that's physically accurate -- gas moves differently than stellar bodies). Each cloud is rendered as a radial gradient with extremely low alpha (0.03 at peak). Individually they're almost invisible. But 30 or 40 of them overlapping? They create those soft nebular wisps you see in Hubble photos. The color palette is limited to purples, blues, and magentas -- the classic nebula range.

This is the layering principle from episode 14: simple elements at low opacity, stacked to create rich visual texture. Works for blobs, works for flow fields, works for nebulae.

Add them to the main loop:

const dustClouds = [];
for (let i = 0; i < 35; i++) {
  dustClouds.push(new DustCloud());
}

function animate(time) {
  ctx.fillStyle = 'rgba(0, 0, 0, 0.08)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);

  // dust behind stars
  for (let cloud of dustClouds) {
    cloud.update(time);
    cloud.draw();
  }

  drawCore();

  for (let star of stars) {
    star.update(time, mouseX, mouseY);
    star.draw();
  }

  requestAnimationFrame(animate);
}

Drawing order matters: dust first, then the core glow, then stars on top. Background to foreground. The dust sits behind everything, creating atmospheric depth without obscuring the star detail.

Adding a few bright stars

Real galaxies have a handful of much brighter, larger stars scattered among the thousands of small ones. These visual anchors draw the eye and make the scale feel real:

function drawBrightStars(time) {
  for (let i = 0; i < 15; i++) {
    // derive position from a dedicated set of "bright" stars
    let angle = i * 0.4 + time * 0.00005 * (i % 3 + 1);
    let dist = 60 + i * 25;
    let x = centerX + Math.cos(angle) * dist;
    let y = centerY + Math.sin(angle) * dist;

    // pulsing glow
    let pulse = 0.5 + 0.5 * Math.sin(time * 0.002 + i * 1.7);
    let glowSize = 6 + pulse * 8;

    // outer glow
    let gradient = ctx.createRadialGradient(x, y, 0, x, y, glowSize);
    gradient.addColorStop(0, `rgba(255, 240, 220, ${0.3 + pulse * 0.2})`);
    gradient.addColorStop(0.4, `rgba(200, 220, 255, ${0.1 + pulse * 0.05})`);
    gradient.addColorStop(1, 'rgba(100, 150, 255, 0)');
    ctx.fillStyle = gradient;
    ctx.fillRect(x - glowSize, y - glowSize, glowSize * 2, glowSize * 2);

    // bright center point
    ctx.beginPath();
    ctx.arc(x, y, 1.5, 0, Math.PI * 2);
    ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
    ctx.fill();
  }
}

Each bright star has a pulsing glow (sin wave with unique phase offset -- there it is again, our most useful animation trick from episode 13) and a tiny bright white center. The glow uses a radial gradient that shifts from warm white at center to cool blue at the edge, simulating the color temperature of hot stars. At 15 bright stars among 3000 regular ones, they're rare enough to feel special.

Add drawBrightStars(time) right after drawing the regular stars in the animation loop.

Making it interactive: shift to repel

The mouse pulls stars toward it by default. Let's add a repulsion mode -- hold shift and the mouse pushes stars away instead:

let repel = false;

window.addEventListener('keydown', (e) => {
  if (e.key === 'Shift') repel = true;
});

window.addEventListener('keyup', (e) => {
  if (e.key === 'Shift') repel = false;
});

Then in the Star's update method, change the force direction:

// in the mouse gravity section of Star.prototype.update:
let direction = repel ? -1 : 1;
this.x += (dx / dist) * force * direction;
this.y += (dy / dist) * force * direction;

One line of multiplication flips attraction to repulsion. Move the mouse through the galaxy while holding shift and stars scatter away from your cursor like fish avoiding a predator. Release shift and they drift back in, pulled by the gravitational well. The push-pull interplay is immediately engaging -- I've spent way too long just waving my cursor through the thing :-) This is the same attract/repel concept from episode 11 where we flipped the sign on our attraction function.

Building in stages

A quick note on process, because I think it matters more than the code. I built this project in stages, not all at once:

  1. First, get the particles spawning in a circle (just trig, no noise)
  2. Then add spiral arm bias
  3. Then add noise wobble to the orbits
  4. Then add mouse interaction
  5. Then add the trail effect
  6. Then add color, the core glow, dust clouds
  7. Then the bright star layer

I tested after each step. If something broke, I knew exactly which addition caused it. This is the creative coding version of writing tests -- except your test is "does it look right?" Trust your eyes. They're the best debugger you have for visual work.

It's also OK to go back and change earlier decisions. I originally had 5 spiral arms and it looked too busy. Dropped to 3 and it felt right. The twist value started at 5 and felt too tight. Dropped to 3. These aren't failures -- they're the creative process. Code is clay. Keep reshaping until it feels right.

The complete galaxy.js

Here's the full thing assembled. You can copy this into a file alongside the HTML from eariler and have a running galaxy:

const canvas = document.getElementById('galaxy');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const centerX = canvas.width / 2;
const centerY = canvas.height / 2;

// --- noise ---
function simpleNoise(x, y) {
  let n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
  return n - Math.floor(n);
}

function smoothNoise(x, y) {
  let ix = Math.floor(x), iy = Math.floor(y);
  let fx = x - ix, fy = y - iy;
  fx = fx * fx * (3 - 2 * fx);
  fy = fy * fy * (3 - 2 * fy);
  let a = simpleNoise(ix, iy);
  let b = simpleNoise(ix + 1, iy);
  let c = simpleNoise(ix, iy + 1);
  let d = simpleNoise(ix + 1, iy + 1);
  return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
}

// --- Star class ---
class Star {
  constructor() {
    this.angle = Math.random() * Math.PI * 2;
    this.distance = Math.random() * Math.min(centerX, centerY) * 0.9;
    this.baseDistance = this.distance;
    this.speed = (0.0005 + Math.random() * 0.002) *
      (1 - this.distance / (centerX * 0.9));
    this.noiseOffsetX = Math.random() * 1000;
    this.noiseOffsetY = Math.random() * 1000;
    this.size = Math.random() * 2.5 + 0.5;
    this.brightness = Math.random();

    let ratio = this.distance / (centerX * 0.9);
    if (ratio < 0.3) {
      this.r = 255;
      this.g = 220 + Math.random() * 35;
      this.b = 180 + Math.random() * 50;
    } else if (ratio < 0.6) {
      this.r = 180 + Math.random() * 75;
      this.g = 200 + Math.random() * 55;
      this.b = 255;
    } else {
      this.r = 120 + Math.random() * 80;
      this.g = 130 + Math.random() * 70;
      this.b = 200 + Math.random() * 55;
    }
  }
}

Star.prototype.update = function(time, mx, my) {
  this.angle += this.speed;
  let wobble = smoothNoise(
    this.noiseOffsetX + time * 0.0003,
    this.noiseOffsetY + time * 0.0003
  ) * 30 - 15;
  this.distance = this.baseDistance + wobble;
  this.x = centerX + Math.cos(this.angle) * this.distance;
  this.y = centerY + Math.sin(this.angle) * this.distance;

  if (mx !== null) {
    let dx = mx - this.x, dy = my - this.y;
    let dist = Math.sqrt(dx * dx + dy * dy);
    if (dist < 200 && dist > 1) {
      let force = (200 - dist) / 200;
      force = force * force * 8;
      let dir = repel ? -1 : 1;
      this.x += (dx / dist) * force * dir;
      this.y += (dy / dist) * force * dir;
    }
  }

  this.alpha = 0.3 + this.brightness * 0.7 *
    (0.7 + 0.3 * Math.sin(time * 0.003 + this.noiseOffsetX));
};

Star.prototype.draw = function() {
  ctx.beginPath();
  ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
  ctx.fillStyle = `rgba(${this.r|0},${this.g|0},${this.b|0},${this.alpha})`;
  ctx.fill();
};

// --- galaxy creation ---
function createGalaxy(count) {
  let stars = [];
  let arms = 3, twist = 3;
  for (let i = 0; i < count; i++) {
    let star = new Star();
    if (Math.random() < 0.6) {
      let arm = Math.floor(Math.random() * arms);
      let armAngle = (arm / arms) * Math.PI * 2;
      let spiralAngle = armAngle +
        (star.distance / (centerX * 0.9)) * twist;
      let spread = 0.3 + (star.distance / (centerX * 0.9)) * 0.5;
      star.angle = spiralAngle + (Math.random() - 0.5) * spread;
    }
    stars.push(star);
  }
  return stars;
}

// --- core glow ---
function drawCore() {
  let g = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, 120);
  g.addColorStop(0, 'rgba(255,240,200,0.15)');
  g.addColorStop(0.3, 'rgba(255,220,180,0.08)');
  g.addColorStop(1, 'rgba(255,200,150,0)');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, canvas.width, canvas.height);
}

// --- input ---
let mouseX = null, mouseY = null, repel = false;
canvas.addEventListener('mousemove', e => { mouseX = e.clientX; mouseY = e.clientY; });
canvas.addEventListener('mouseleave', () => { mouseX = null; mouseY = null; });
window.addEventListener('keydown', e => { if (e.key === 'Shift') repel = true; });
window.addEventListener('keyup', e => { if (e.key === 'Shift') repel = false; });

// --- create ---
const stars = createGalaxy(3000);

// --- animate ---
function animate(time) {
  ctx.fillStyle = 'rgba(0,0,0,0.08)';
  ctx.fillRect(0, 0, canvas.width, canvas.height);
  drawCore();
  for (let s of stars) { s.update(time, mouseX, mouseY); s.draw(); }
  requestAnimationFrame(animate);
}

requestAnimationFrame(animate);

What we combined

Look at what's in this one sketch:

  • Particle systems (ep 11) -- thousands of independant objects with properties and update/draw loops
  • Perlin noise concepts (ep 12) -- value noise for organic wobble on orbital radii
  • Trigonometry (ep 13) -- polar coordinates for orbital motion, sin waves for twinkle
  • Bezier/curve concepts (ep 14) -- smooth, organic trajectories instead of rigid geometry
  • Vanilla Canvas (ep 9) -- raw API, no library, radial gradients, composite operations
  • Color theory (ep 7) -- warm core, cool edges, intentional palette for emotional effect
  • Interactivity (ep 6) -- mouse as gravity well, keyboard for mode switching

Seven episodes of technique, one unified piece. That's creative coding. And the individual pieces aren't complicated -- particle spawning is 5 lines, gravity is 4 lines, noise wobble is 2 lines. But when 3000 particles are all doing it simultaneously with mouse interaction layered on top, the result feels like it took months to build. It didn't. It took learning the fundamentals and then combining them.

Tweaks to make it yours

This is a mini-project, so make it yours. Here are some ideas to push it further:

Dust lanes -- dark patches between the spiral arms. Add some "anti-stars" that draw in near-black instead of bright. They create negative space that makes the arms pop:

// in createGalaxy, for some percentage of stars:
if (Math.random() < 0.08) {
  star.r = 5; star.g = 5; star.b = 8;
  star.size = Math.random() * 4 + 2;
  star.brightness = 0.2;
}

Zoom with scroll wheel -- scale everything based on a zoom factor. Multiply all distances by a zoom variable and adjust it on wheel events:

let zoom = 1.0;
canvas.addEventListener('wheel', (e) => {
  zoom *= e.deltaY > 0 ? 0.95 : 1.05;
  zoom = Math.max(0.3, Math.min(3, zoom));
  e.preventDefault();
});

// in Star.prototype.update, replace the cartesian conversion:
this.x = centerX + Math.cos(this.angle) * this.distance * zoom;
this.y = centerY + Math.sin(this.angle) * this.distance * zoom;

Color shift over time -- slowly rotate the entire color palette so the galaxy transforms from warm to cool and back:

// in animate(), before the star loop:
let colorShift = Math.sin(time * 0.0001) * 30;
// then in Star.prototype.draw, offset the r/g/b values:
ctx.fillStyle = `rgba(${(this.r + colorShift)|0},${this.g|0},${(this.b - colorShift)|0},${this.alpha})`;

Each tweak is just another layer -- another simple system added to the mix. That layering principle from episode 14 applies to projects too, not just individual techniques.

What we learned about combining systems

The galaxy doesn't look good because any single technique is impressive -- it looks good because multiple simple systems interact. The star particles respond to orbital mechanics AND noise AND mouse gravity. The visual result emerges from the interaction of all these forces, not from any one of them individually.

This is a pattern you'll use in every serious creative coding piece: layer simple systems, let them interact, and watch complexity emerge. It's the generative art version of "the whole is greater than the sum of its parts." If your sketch looks flat or boring, the answer is almost never "make one thing more complex" -- it's "add another simple thing and let it interfere with what's already there."

We're going to keep building on this principle as we move forward. There's a lot more to learn about how to make motion feel natural, how to control timing and transitions, and how to add sound-reactive elements that make your visuals respond to audio. The tools get more powerful but the philosophy stays the same: simple rules, complex results.

't Komt erop neer...

  • Spiral galaxies = particles on orbits biased toward logarithmic spiral arms, with spread increasing at the edges
  • Inner stars orbit faster, outer stars orbit slower -- creates visual depth and a sense of scale
  • Noise wobble on the orbital radius makes rigid circles feel organic and alive
  • The polar-to-cartesian formula (cos(angle) * distance, sin(angle) * distance) powers every orbital motion
  • Mouse gravity = calculate distance and direction, apply force with quadratic falloff -- same as episode 11's attraction model
  • Phase offsets on sin waves give each star independent twinkle timing
  • Semi-transparent clear (rgba(0,0,0,0.08)) creates gorgeous motion trailing
  • Core glow = radial gradient, subtle but essential for selling the illusion
  • Layer simple systems (stars + noise + gravity + dust + glow) and complexity emerges from their interaction
  • Build projects in stages: add one behavior at a time, test after each addition, trust your eyes

That's Phase 2 done! We've gone from the raw Canvas API in episode 9 to building a full interactive galaxy in six episodes. If you told me at episode 9 that we'd be simulating spiral galaxies by episode 15, I might not have believed you. But each episode gave us one more tool, and tools compose.

Phase 3 is next: Animation and Motion. We're going to dig into how to make motion feel natural and polished -- smooth transitions, controlled timing, basic physics simulations, and eventually some really fun stuff with sound-reactive visuals. The galaxy is cool but it moves in a constant, steady way. What if motion could accelerate, decelerate, bounce, spring back, and respond to rhythm? That's where we're headed :-)

Sallukes! Thanks for reading.

X

@femdev

Sort:  

Super cool stuff Char!

ps, sorry to see that I'm about the only one voting on your posts! And good to see you're still hang in there. Did you reach out to stemSocial curators if perhaps they havne't seen your posts? I know how much time you're spending on crafting these so TBH you deserve a lot more support.

I think stemSocial has a discord server... Not sure if I'm in there myself.
Do you even have a Discord account Char?

Love ...
ps gaan we nog eens bieren in ANtwerp binnenkort? En dan doorzakken in zo'n gezellig klein bruin kroegje, ik vergeet iedere keer waar we toen overlaatst stonden met oud en nieuw 2024, of was het 2023?


Your reply is upvoted by @topcomment; a manual curation service that rewards meaningful and engaging comments.

More Info - Support us! - Reports - Discord Channel

topcommentbanner.png
Curated by kpoulout

Congratulations @femdev! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)

You published more than 40 posts.
Your next target is to reach 50 posts.

You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP

Combining past lessons feels right.