How to Build a Generative Art Piece in Under 50KB: A Step-by-Step Tutorial
You don't need a game engine, a framework, or even npm to make generative art. All you need is a single HTML file, a <canvas> element, and some curiosity. In this tutorial, we'll build a complete generative artwork from scratch — and keep the entire thing under 50KB.
This is the same constraint used by Lumitree's micro-worlds and inspired by the demoscene tradition of making extraordinary things in tiny spaces. The limit isn't a handicap — it's a creative superpower.
What we're building
By the end of this tutorial, you'll have a self-contained HTML file that generates an animated particle landscape — glowing particles that drift, connect, and respond to mouse movement, with procedural colors that shift over time. It works in any browser, loads instantly, and weighs about 3KB.
Step 1: The skeleton
Every generative art piece starts the same way: a full-screen canvas and an animation loop.
<!DOCTYPE html>
<html>
<body style="margin:0;overflow:hidden;background:#0a0a0f">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
const ctx = c.getContext('2d');
function resize() {
c.width = innerWidth;
c.height = innerHeight;
}
resize();
addEventListener('resize', resize);
function draw(t) {
// We'll fill this in
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script>
</body>
</html>
That's your foundation. A black canvas that fills the screen and calls draw() 60 times per second. The t parameter is a timestamp in milliseconds — your clock for animations. This skeleton is about 400 bytes.
Step 2: Particles — the atoms of generative art
Particles are the workhorse of generative art. A particle is just an object with a position, a velocity, and some visual properties. Let's create 200 of them:
const N = 200;
const particles = Array.from({length: N}, () => ({
x: Math.random() * innerWidth,
y: Math.random() * innerHeight,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
r: Math.random() * 2 + 1,
}));
Each particle has a position (x, y), velocity (vx, vy), and radius (r). The velocities are small — we want a gentle drift, not a fireworks show.
Step 3: Movement and wrapping
In the draw loop, move each particle by its velocity. When particles leave the screen, wrap them to the opposite edge:
function update() {
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0) p.x = c.width;
if (p.x > c.width) p.x = 0;
if (p.y < 0) p.y = c.height;
if (p.y > c.height) p.y = 0;
}
}
This creates an infinite field effect. Particles never disappear — the world is a torus, wrapping in both directions.
Step 4: Rendering with glow
Now draw the particles. The trick to making simple circles look like generative art: use transparency and radial gradients for a soft glow effect.
function render(t) {
// Semi-transparent background for motion trails
ctx.fillStyle = 'rgba(10, 10, 15, 0.15)';
ctx.fillRect(0, 0, c.width, c.height);
for (const p of particles) {
// Hue shifts over time — each particle has a slightly different color
const hue = (t * 0.02 + p.x * 0.1 + p.y * 0.1) % 360;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, 80%, 60%, 0.8)`;
ctx.fill();
}
}
Two key techniques here:
- Motion trails: Instead of clearing the canvas each frame with a solid color, we draw a semi-transparent rectangle. Previous frames show through, creating soft trails behind moving particles.
- HSL color cycling: Using
hsla()with a hue that depends on time and position creates a rainbow effect that continuously shifts. This single line replaces a complex color palette system.
Step 5: Connections — the magic ingredient
Individual particles look like fireflies. But connect nearby particles with lines and suddenly you have a living network — a visualization that looks complex despite being algorithmically simple.
function drawConnections(t) {
const maxDist = 120;
for (let i = 0; i < N; i++) {
for (let j = i + 1; j < N; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < maxDist) {
const alpha = 1 - dist / maxDist;
const hue = (t * 0.02 + particles[i].x * 0.05) % 360;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.strokeStyle = `hsla(${hue}, 70%, 50%, ${alpha * 0.3})`;
ctx.lineWidth = alpha;
ctx.stroke();
}
}
}
}
The key is the alpha calculation: lines fade out as particles move apart. This creates an organic network that constantly reshapes itself — constellations forming and dissolving. This is one of the most satisfying effects in generative art, and it's just a distance check in a nested loop.
Step 6: Mouse interaction
Great generative art responds to the viewer. Let's make particles gently attracted to the mouse cursor:
let mx = innerWidth / 2, my = innerHeight / 2;
addEventListener('mousemove', e => { mx = e.clientX; my = e.clientY; });
addEventListener('touchmove', e => {
mx = e.touches[0].clientX;
my = e.touches[0].clientY;
});
// Add to the update function:
function update() {
for (const p of particles) {
// Gentle mouse attraction
const dx = mx - p.x;
const dy = my - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 200 && dist > 0) {
p.vx += dx / dist * 0.02;
p.vy += dy / dist * 0.02;
}
// Apply velocity with damping
p.x += p.vx;
p.y += p.vy;
p.vx *= 0.99;
p.vy *= 0.99;
// Wrap edges
if (p.x < 0) p.x = c.width;
if (p.x > c.width) p.x = 0;
if (p.y < 0) p.y = c.height;
if (p.y > c.height) p.y = 0;
}
}
The attraction force is inversely proportional to distance — closer particles are pulled harder. The 0.99 damping prevents particles from accelerating infinitely. This creates a satisfying flocking behavior around your cursor.
Step 7: Put it all together
Here's the complete artwork in one copy-pasteable file:
<!DOCTYPE html>
<html>
<body style="margin:0;overflow:hidden;background:#0a0a0f">
<canvas id="c"></canvas>
<script>
const c = document.getElementById('c');
const x = c.getContext('2d');
const N = 200;
let mx = innerWidth/2, my = innerHeight/2;
function resize() { c.width = innerWidth; c.height = innerHeight; }
resize();
addEventListener('resize', resize);
addEventListener('mousemove', e => { mx=e.clientX; my=e.clientY; });
addEventListener('touchmove', e => { mx=e.touches[0].clientX; my=e.touches[0].clientY; });
const P = Array.from({length:N}, () => ({
x: Math.random()*innerWidth,
y: Math.random()*innerHeight,
vx: (Math.random()-.5)*.5,
vy: (Math.random()-.5)*.5,
r: Math.random()*2+1
}));
function draw(t) {
x.fillStyle = 'rgba(10,10,15,0.15)';
x.fillRect(0,0,c.width,c.height);
for (const p of P) {
const dx=mx-p.x, dy=my-p.y;
const d=Math.sqrt(dx*dx+dy*dy);
if(d<200&&d>0){p.vx+=dx/d*.02;p.vy+=dy/d*.02;}
p.x+=p.vx; p.y+=p.vy;
p.vx*=.99; p.vy*=.99;
if(p.x<0)p.x=c.width;if(p.x>c.width)p.x=0;
if(p.y<0)p.y=c.height;if(p.y>c.height)p.y=0;
const h=(t*.02+p.x*.1+p.y*.1)%360;
x.beginPath();x.arc(p.x,p.y,p.r,0,Math.PI*2);
x.fillStyle=`hsla(${h},80%,60%,.8)`;x.fill();
}
for(let i=0;i<N;i++) for(let j=i+1;j<N;j++){
const dx=P[i].x-P[j].x,dy=P[i].y-P[j].y;
const d=Math.sqrt(dx*dx+dy*dy);
if(d<120){
const a=1-d/120;
const h=(t*.02+P[i].x*.05)%360;
x.beginPath();x.moveTo(P[i].x,P[i].y);x.lineTo(P[j].x,P[j].y);
x.strokeStyle=`hsla(${h},70%,50%,${a*.3})`;
x.lineWidth=a;x.stroke();
}
}
requestAnimationFrame(draw);
}
requestAnimationFrame(draw);
</script>
</body>
</html>
That's a complete, interactive generative artwork in about 1.2KB. Open it in a browser. Move your mouse around. Watch the constellations form and dissolve. This is generative art.
Going further: techniques to explore
Once you have the basics, here are directions to take your 50KB art further:
- Perlin noise: Replace random velocities with noise-driven flow fields. Particles follow invisible currents, creating organic, river-like patterns. You can implement 2D Perlin noise in about 40 lines of JavaScript.
- WebGL shaders: Move from Canvas 2D to fragment shaders for GPU-powered visuals. The raymarching technique described in our Anatomy of a Shader World article can create entire 3D scenes mathematically.
- Audio reactivity: Use the Web Audio API's
AnalyserNodeto make your art respond to music. Map frequency bands to particle size, speed, or color for a built-in visualizer. - SVG instead of Canvas: For resolution-independent art, generate SVG elements dynamically. Great for geometric patterns, line art, and printable generative designs.
- CSS-only art: Push the boundaries by using CSS animations, gradients, and clip-path to create generative-looking art with zero JavaScript. The results can be surprisingly complex.
The 50KB philosophy
Why limit yourself to 50KB? Because constraints are generative. When you can't import a library, you learn how the Canvas API actually works. When you can't load an image, you learn to generate textures with math. When every byte counts, you write code that is pure — every line serves a purpose.
The demoscene has known this for decades: the most creative code emerges from the tightest constraints. A 4KB demo that simulates an ocean is more impressive than a 40MB Unity scene that does the same thing — and the skills you develop working small transfer directly to working large.
This is why every micro-world on Lumitree lives under 50KB. Each branch on the tree is a complete artwork in a single HTML file — no build step, no dependencies, no frameworks. Just code and creativity.
Your turn
Copy the code above. Save it as art.html. Open it in a browser. Then start changing things: adjust the particle count, modify the connection distance, experiment with color formulas, add new forces. Every change produces something different. That's the joy of generative art — you're not drawing a picture, you're designing a system that draws infinite pictures.
When you make something you love, plant it as a branch on Lumitree. Your creation becomes part of a tree that never stops growing.