This node garden implementation is just one example. There are many different expressions and implementations out there, so please consider this as a reference.
First, See the Final Result
This is what we're building.
Introduction
When creating generative art, you usually have an image in your head of "I want it to move like this." But when you actually try to implement it, it looks complex and you don't know where to start.
In such cases, the approach of "breaking it down into elements and building up from the bottom" might work well.
In this article, I'll introduce this approach using a node garden — a classic generative art implementation — as an example.
Verbalize Your Image
First, put the vague image in your head into words.
For this node garden, I imagined it like this:
- Points floating around
- Lines connect when they get close
- They influence each other and change movement
- Something organic feeling
Even vague things like "something organic" should be written down for now.
Break Down Into Elements
Next, break down each description into concrete processes.
| Image | Process |
|---|---|
| Points floating | Drawing points, movement with velocity |
| Connect when close | Distance calculation, threshold check, line drawing |
| Influence each other / Organic feeling | Force from charge, line stretch animation |
The way you break things down will vary by person. I often separate "visible elements" from "movement rules."
Organize as Layer Structure
Organize the elements into a layer structure while considering dependencies.
For a simple node garden, Layer 3 might be enough.
Layer 4: Charge + Stretch Animation
↑
Layer 3: Line Connection & Opacity
↑
Layer 2: Point Movement & Screen Wrap
↑
Layer 1: Point Drawing
This is a dependency structure where upper layers can't exist without lower layers.
For example, to implement "force from charge," you first need "distance calculation," and to calculate distance, you need "point positions"... and so on.
Build From the Bottom
Now let's actually write the code. The key point is to verify at each layer.
Layer 1: Point Drawing
First, just draw points.
If white points are randomly displayed on the screen, it's OK.
const CONFIG = {
width: 340,
height: 340,
numNodes: 60,
nodeRadius: 3,
};
class Node {
constructor(x, y) {
this.x = x;
this.y = y;
this.radius = CONFIG.nodeRadius;
}
draw(ctx) {
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
}
}
// Initialize
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const nodes = Array.from({ length: CONFIG.numNodes }, () =>
new Node(
Math.random() * CONFIG.width,
Math.random() * CONFIG.height
)
);
// Draw
ctx.clearRect(0, 0, CONFIG.width, CONFIG.height);
nodes.forEach(node => node.draw(ctx));
Layer 2: Point Movement & Screen Wrap
Give points velocity and make them move.
If points are floating around, it's a success.
class Node {
constructor(x, y) {
this.x = x;
this.y = y;
this.vx = Math.random() * 3 - 1.5;
this.vy = Math.random() * 3 - 1.5;
this.radius = CONFIG.nodeRadius;
}
update() {
this.x += this.vx;
this.y += this.vy;
// Wrap to opposite side when reaching screen edge
if (this.x > CONFIG.width) this.x = 0;
else if (this.x < 0) this.x = CONFIG.width;
if (this.y > CONFIG.height) this.y = 0;
else if (this.y < 0) this.y = CONFIG.height;
}
draw(ctx) {
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
ctx.fill();
}
}
// Animation loop
function loop() {
ctx.clearRect(0, 0, CONFIG.width, CONFIG.height);
nodes.forEach(node => {
node.update();
node.draw(ctx);
});
requestAnimationFrame(loop);
}
loop();
Layer 3: Line Connection & Opacity
Calculate distance between points and draw lines if within threshold.
If nearby points are connected with lines and opacity changes with distance, it's a success.
Here's one trick: when nodes wrap at the screen edge, lines suddenly disappear which looks unnatural. To prevent this, we set the wrap boundary larger than the drawing area (margin: 100). Nodes go off-screen and lines naturally fade out before wrapping to the opposite side.
Up to Layer 2, it's just nodes so it's not noticeable, but from Layer 3 onward where lines connect, this technique becomes important.
const CONFIG = {
// ...
margin: 100,
minDist: 100,
lineWidth: 1.5,
};
class Node {
// ...
update() {
this.x += this.vx;
this.y += this.vy;
// Wrap outside screen edge (so lines fade naturally)
const m = CONFIG.margin;
if (this.x > CONFIG.width + m) this.x = -m;
else if (this.x < -m) this.x = CONFIG.width + m;
if (this.y > CONFIG.height + m) this.y = -m;
else if (this.y < -m) this.y = CONFIG.height + m;
}
distanceTo(other) {
const dx = this.x - other.x;
const dy = this.y - other.y;
return { dx, dy, dist: Math.sqrt(dx * dx + dy * dy) };
}
}
function loop() {
ctx.clearRect(0, 0, CONFIG.width, CONFIG.height);
ctx.lineWidth = CONFIG.lineWidth;
for (let i = 0; i < nodes.length; i++) {
const node1 = nodes[i];
node1.update();
node1.draw(ctx);
for (let j = i + 1; j < nodes.length; j++) {
const node2 = nodes[j];
const { dist } = node1.distanceTo(node2);
if (dist < CONFIG.minDist) {
// Closer distance = more opaque
const opacity = 1 - dist / CONFIG.minDist;
ctx.beginPath();
ctx.strokeStyle = `rgba(255, 255, 255, ${opacity})`;
ctx.moveTo(node1.x, node1.y);
ctx.lineTo(node2.x, node2.y);
ctx.stroke();
}
}
}
requestAnimationFrame(loop);
}
Layer 4: Charge + Stretch Animation
Give each node a random "charge" so pairs attract or repel each other. Also, instead of lines appearing suddenly, add an animation where they extend from both ends toward the center.
By multiplying charges, we express attraction and repulsion.
interaction = charge1 × charge2
- Same sign (+ × + or − × −) → Positive → Repel
- Opposite sign (+ × − or − × +) → Negative → Attract
| Charge (Node) 1 | Charge (Node) 2 | Result | Movement |
|---|---|---|---|
| + | + | + | Repel |
| − | − | + | Repel |
| + | − | − | Attract |
| − | + | − | Attract |
Nodes that were moving similarly now have personality, and movement becomes more diverse.
const CONFIG = {
// ...
margin: 100,
springAmount: 0.0005,
maxSpeed: 1.5,
lineGrowSpeed: 0.05,
};
class Node {
constructor(x, y) {
// ...
// Random charge from -1 to 1
this.charge = Math.random() * 2 - 1;
}
update() {
// Speed limit
const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy);
if (speed > CONFIG.maxSpeed) {
this.vx = (this.vx / speed) * CONFIG.maxSpeed;
this.vy = (this.vy / speed) * CONFIG.maxSpeed;
}
this.x += this.vx;
this.y += this.vy;
// Wrap outside screen edge (so lines fade naturally)
const m = CONFIG.margin;
if (this.x > CONFIG.width + m) this.x = -m;
else if (this.x < -m) this.x = CONFIG.width + m;
if (this.y > CONFIG.height + m) this.y = -m;
else if (this.y < -m) this.y = CONFIG.height + m;
}
applyForce(ax, ay) {
this.vx += ax;
this.vy += ay;
}
}
// Manage connection state
const connections = new Map();
function getConnectionKey(i, j) {
return i < j ? `${i}-${j}` : `${j}-${i}`;
}
function drawConnection(node1, node2, opacity, progress) {
const midX = (node1.x + node2.x) / 2;
const midY = (node1.y + node2.y) / 2;
// Extend toward midpoint based on progress
const x1 = node1.x + (midX - node1.x) * progress;
const y1 = node1.y + (midY - node1.y) * progress;
const x2 = node2.x + (midX - node2.x) * progress;
const y2 = node2.y + (midY - node2.y) * progress;
ctx.beginPath();
ctx.strokeStyle = `rgba(255, 255, 255, ${opacity})`;
ctx.moveTo(node1.x, node1.y);
ctx.lineTo(x1, y1);
ctx.moveTo(node2.x, node2.y);
ctx.lineTo(x2, y2);
ctx.stroke();
}
// Inside loop
if (dist < CONFIG.minDist) {
const key = getConnectionKey(i, j);
const currentProgress = connections.get(key) || 0;
const newProgress = Math.min(1, currentProgress + CONFIG.lineGrowSpeed);
connections.set(key, newProgress);
const opacity = 1 - dist / CONFIG.minDist;
drawConnection(node1, node2, opacity, newProgress);
// Multiply charges (same sign → repel, opposite sign → attract)
const interaction = node1.charge * node2.charge;
const ax = dx * CONFIG.springAmount * interaction;
const ay = dy * CONFIG.springAmount * interaction;
node1.applyForce(ax, ay);
node2.applyForce(-ax, -ay);
}
Conclusion
What's important in implementing art expressions is this process of taking the image in your head and:
- Verbalizing it
- Breaking it into elements
- Organizing as layers
- Building from the bottom
At each layer, you can verify "it's working, it's correct," so it's easy to see where problems occur. You can also decide mid-way "I don't need this element" and remove it.
Top comments (0)