Genetic Algorithm: How to Create Evolving Art and Optimization With Code
Genetic algorithms are evolution in a bottle. You start with random solutions, test them against a goal, let the best ones breed, sprinkle in some mutation, and repeat. After hundreds or thousands of generations, designs emerge that no human would have drawn by hand — optimized, surprising, and often beautiful.
This article builds 8 genetic algorithm visualizations from scratch using JavaScript and Canvas. Every example runs live in your browser, and every one shows evolution happening in real time. We start with the classic string-matching GA, then evolve polygon art, solve the traveling salesman problem, breed color palettes, grow walking creatures, navigate mazes with neuroevolution, run interactive aesthetic selection, and finish with a full generative art evolver.
1. Evolving a Target String
The "Hello World" of genetic algorithms: evolve a random string of characters into a target string. Each individual is a string of characters, fitness is the number of matching characters, and we use tournament selection + single-point crossover + character mutation. This example makes the core GA loop tangible — you can watch the population converge letter by letter.
<!DOCTYPE html><html><body style="margin:0;background:#111;color:#eee;font-family:monospace;overflow:hidden">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const TARGET='GENETIC ALGORITHMS ARE BEAUTIFUL';
const CHARS='ABCDEFGHIJKLMNOPQRSTUVWXYZ ';
const POP=200,MUTATION_RATE=0.02;
let pop=Array.from({length:POP},()=>({genes:Array.from({length:TARGET.length},()=>CHARS[Math.random()*CHARS.length|0]).join('')}));
let gen=0,bestEver={genes:'',fitness:0},history=[];
function fitness(ind){let f=0;for(let i=0;i<TARGET.length;i++)if(ind.genes[i]===TARGET[i])f++;return f/TARGET.length}
function tournament(){const a=pop[Math.random()*POP|0],b=pop[Math.random()*POP|0];return fitness(a)>fitness(b)?a:b}
function crossover(a,b){const pt=Math.random()*TARGET.length|0;return{genes:a.genes.slice(0,pt)+b.genes.slice(pt)}}
function mutate(ind){let g=[...ind.genes];for(let i=0;i<g.length;i++)if(Math.random()<MUTATION_RATE)g[i]=CHARS[Math.random()*CHARS.length|0];return{genes:g.join('')}}
function step(){
pop.forEach(p=>p.fit=fitness(p));
pop.sort((a,b)=>b.fit-a.fit);
if(pop[0].fit>bestEver.fitness){bestEver={genes:pop[0].genes,fitness:pop[0].fit}}
history.push(pop[0].fit);
const next=[pop[0]];// elitism
while(next.length<POP)next.push(mutate(crossover(tournament(),tournament())));
pop=next;gen++;
}
function draw(){
if(bestEver.fitness<1)step();
x.fillStyle='#111';x.fillRect(0,0,W,H);
x.fillStyle='#aaa';x.font='12px monospace';
x.fillText('Generation: '+gen,20,25);
x.fillText('Best fitness: '+(bestEver.fitness*100).toFixed(1)+'%',20,42);
// Draw best string with color-coded chars
x.font='18px monospace';
let bx=20;
for(let i=0;i<TARGET.length;i++){
const match=bestEver.genes[i]===TARGET[i];
x.fillStyle=match?'#4f8':'#f55';
x.fillText(bestEver.genes[i]||' ',bx,80);
bx+=12;
}
x.fillStyle='#555';x.font='12px monospace';
x.fillText('Target: '+TARGET,20,105);
// Draw top 10 individuals
x.fillStyle='#888';x.fillText('Top 10 individuals:',20,135);
for(let i=0;i<Math.min(10,pop.length);i++){
const p=pop[i],f=fitness(p);
x.fillStyle=`hsl(${f*120},70%,60%)`;
x.fillText((f*100).toFixed(0).padStart(3)+'% '+p.genes,20,155+i*16);
}
// Fitness graph
x.strokeStyle='#4f8';x.lineWidth=1;x.beginPath();
const gx=20,gy=340,gw=W-40,gh=90;
x.strokeStyle='#333';x.strokeRect(gx,gy,gw,gh);
if(history.length>1){
x.strokeStyle='#4f8';x.beginPath();
for(let i=0;i<history.length;i++){
const px=gx+i/Math.max(history.length-1,1)*gw;
const py=gy+gh-history[i]*gh;
i===0?x.moveTo(px,py):x.lineTo(px,py);
}
x.stroke();
}
x.fillStyle='#555';x.fillText('Fitness over generations',gx,gy-5);
requestAnimationFrame(draw);
}
draw();
</script></body></html>
Watch how the population quickly finds easy letters (common ones like spaces and E's) and then slowly converges on the harder positions. This is selection pressure in action — tournament selection ensures that better solutions breed more often, while mutation provides the random variation needed to escape local optima.
2. Evolving Polygon Art
One of the most famous GA visualizations: approximate a target shape using semi-transparent polygons. Each individual is a set of colored triangles. Fitness is measured by pixel-level similarity to a target. Over thousands of generations, blobs of color arrange themselves into a recognizable approximation. We use a simple target (a colored circle) to keep it self-contained.
<!DOCTYPE html><html><body style="margin:0;background:#111;color:#eee;font-family:monospace">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const S=120;// render size for comparison
const NUM_POLYS=30,POP=30,MUTATION_RATE=0.05;
// Create target image: colorful circle
const tc=document.createElement('canvas');tc.width=tc.height=S;
const tx=tc.getContext('2d');
tx.fillStyle='#000';tx.fillRect(0,0,S,S);
const tg=tx.createRadialGradient(S/2,S/2,0,S/2,S/2,S/2);
tg.addColorStop(0,'#f44');tg.addColorStop(0.4,'#fa0');tg.addColorStop(0.7,'#4af');tg.addColorStop(1,'#000');
tx.fillStyle=tg;tx.beginPath();tx.arc(S/2,S/2,S*0.4,0,Math.PI*2);tx.fill();
const targetData=tx.getImageData(0,0,S,S).data;
function randomPoly(){return{pts:Array.from({length:3},()=>[Math.random(),Math.random()]),r:Math.random()*255|0,g:Math.random()*255|0,b:Math.random()*255|0,a:Math.random()*0.5+0.1}}
function randomInd(){return{polys:Array.from({length:NUM_POLYS},randomPoly)}}
function renderInd(ind,ctx,s){
ctx.fillStyle='#000';ctx.fillRect(0,0,s,s);
for(const p of ind.polys){
ctx.fillStyle=`rgba(${p.r},${p.g},${p.b},${p.a.toFixed(2)})`;
ctx.beginPath();ctx.moveTo(p.pts[0][0]*s,p.pts[0][1]*s);
ctx.lineTo(p.pts[1][0]*s,p.pts[1][1]*s);ctx.lineTo(p.pts[2][0]*s,p.pts[2][1]*s);
ctx.closePath();ctx.fill();
}
}
const fc=document.createElement('canvas');fc.width=fc.height=S;const fx=fc.getContext('2d');
function fitness(ind){
renderInd(ind,fx,S);
const d=fx.getImageData(0,0,S,S).data;
let diff=0;
for(let i=0;i<d.length;i+=4){diff+=Math.abs(d[i]-targetData[i])+Math.abs(d[i+1]-targetData[i+1])+Math.abs(d[i+2]-targetData[i+2])}
return 1-diff/(S*S*255*3);
}
function clamp(v,mn,mx){return Math.max(mn,Math.min(mx,v))}
function mutate(ind){
const n={polys:ind.polys.map(p=>({...p,pts:p.pts.map(pt=>[...pt])}))};
for(const p of n.polys){
if(Math.random()<MUTATION_RATE){for(let i=0;i<3;i++){p.pts[i][0]=clamp(p.pts[i][0]+Math.random()*0.2-0.1,0,1);p.pts[i][1]=clamp(p.pts[i][1]+Math.random()*0.2-0.1,0,1)}}
if(Math.random()<MUTATION_RATE){p.r=clamp(p.r+(Math.random()*60-30|0),0,255);p.g=clamp(p.g+(Math.random()*60-30|0),0,255);p.b=clamp(p.b+(Math.random()*60-30|0),0,255)}
if(Math.random()<MUTATION_RATE)p.a=clamp(p.a+Math.random()*0.2-0.1,0.05,0.8);
}
return n;
}
function crossover(a,b){const pt=Math.random()*NUM_POLYS|0;return{polys:[...a.polys.slice(0,pt).map(p=>({...p,pts:p.pts.map(pt=>[...pt])})),...b.polys.slice(pt).map(p=>({...p,pts:p.pts.map(pt=>[...pt])}))]}}
let pop=Array.from({length:POP},randomInd),gen=0,bestFit=0,history=[];
function step(){
pop.forEach(p=>p.fit=fitness(p));
pop.sort((a,b)=>b.fit-a.fit);
if(pop[0].fit>bestFit)bestFit=pop[0].fit;
history.push(bestFit);
const next=[pop[0]];
while(next.length<POP){
const pa=pop[Math.random()*POP*0.4|0],pb=pop[Math.random()*POP*0.4|0];
next.push(mutate(crossover(pa,pb)));
}
pop=next;gen++;
}
function draw(){
for(let i=0;i<3;i++)step();// 3 gens per frame
x.fillStyle='#111';x.fillRect(0,0,W,H);
// Target
x.fillStyle='#888';x.font='12px monospace';
x.fillText('Target',30,25);
x.drawImage(tc,30,35,150,150);
// Best individual
x.fillText('Best (gen '+gen+')',210,25);
const bc=document.createElement('canvas');bc.width=bc.height=S;const bx=bc.getContext('2d');
renderInd(pop[0],bx,S);
x.drawImage(bc,210,35,150,150);
// Info
x.fillStyle='#4f8';x.fillText('Fitness: '+(bestFit*100).toFixed(2)+'%',400,55);
x.fillStyle='#aaa';x.fillText('Polygons: '+NUM_POLYS,400,75);
x.fillText('Population: '+POP,400,95);
// Grid of top 8
x.fillStyle='#555';x.fillText('Top 8 individuals:',30,210);
for(let i=0;i<8;i++){
const ic=document.createElement('canvas');ic.width=ic.height=S;const ix=ic.getContext('2d');
renderInd(pop[i],ix,S);
x.drawImage(ic,30+(i%4)*140,225+((i/4)|0)*130,120,120);
}
// Fitness graph
if(history.length>1){
x.strokeStyle='#4f8';x.lineWidth=1;x.beginPath();
for(let i=0;i<history.length;i++){
const px=30+i/Math.max(history.length-1,1)*540;
const py=H-10-history[i]*(H-400);
i===0?x.moveTo(px,py):x.lineTo(px,py);
}
x.stroke();
}
requestAnimationFrame(draw);
}
draw();
</script></body></html>
This is Roger Alsing's polygon evolution brought to life. The algorithm can't "see" the target — it only knows how different its rendering is from the goal. Yet through blind variation and selection, it discovers which shapes, colors, and positions reduce that difference. The fitness landscape is incredibly high-dimensional (each polygon has 11 parameters × 30 polygons = 330 dimensions), but evolution navigates it efficiently.
3. Traveling Salesman Problem
The Traveling Salesman Problem (TSP) asks: given N cities, what is the shortest route that visits all cities exactly once and returns to the start? It is NP-hard — brute force is impossible for more than ~20 cities. Genetic algorithms find near-optimal solutions by evolving permutations of city indices using ordered crossover (OX) and swap mutation.
<!DOCTYPE html><html><body style="margin:0;background:#111;color:#eee;font-family:monospace">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const N=30,POP=150,MUTATION_RATE=0.15,ELITISM=5;
const cities=Array.from({length:N},()=>({x:40+Math.random()*(W-80),y:40+Math.random()*(H-120)}));
function dist(a,b){const dx=a.x-b.x,dy=a.y-b.y;return Math.sqrt(dx*dx+dy*dy)}
function routeLen(r){let d=0;for(let i=0;i<r.length;i++)d+=dist(cities[r[i]],cities[r[(i+1)%r.length]]);return d}
function shuffle(a){const b=[...a];for(let i=b.length-1;i>0;i--){const j=Math.random()*(i+1)|0;[b[i],b[j]]=[b[j],b[i]]}return b}
function oxCrossover(p1,p2){
let a=Math.random()*N|0,b=Math.random()*N|0;
if(a>b)[a,b]=[b,a];
const child=new Array(N).fill(-1);
for(let i=a;i<=b;i++)child[i]=p1[i];
let ci=(b+1)%N;
for(let i=0;i<N;i++){const gi=p2[(b+1+i)%N];if(!child.includes(gi)){child[ci]=gi;ci=(ci+1)%N}}
return child;
}
function mutate(r){
const m=[...r];
for(let i=0;i<N;i++){
if(Math.random()<MUTATION_RATE){const j=Math.random()*N|0;[m[i],m[j]]=[m[j],m[i]]}
}
return m;
}
const base=Array.from({length:N},(_,i)=>i);
let pop=Array.from({length:POP},()=>shuffle(base));
let gen=0,bestRoute=pop[0],bestDist=routeLen(pop[0]),history=[];
function step(){
const fits=pop.map(r=>({route:r,dist:routeLen(r)}));
fits.sort((a,b)=>a.dist-b.dist);
if(fits[0].dist<bestDist){bestDist=fits[0].dist;bestRoute=[...fits[0].route]}
history.push(bestDist);
const next=fits.slice(0,ELITISM).map(f=>[...f.route]);
while(next.length<POP){
const pa=fits[Math.random()*POP*0.3|0].route;
const pb=fits[Math.random()*POP*0.3|0].route;
next.push(mutate(oxCrossover(pa,pb)));
}
pop=next;gen++;
}
function draw(){
for(let i=0;i<5;i++)step();
x.fillStyle='#111';x.fillRect(0,0,W,H);
// Draw best route
x.strokeStyle='rgba(100,255,150,0.6)';x.lineWidth=2;x.beginPath();
for(let i=0;i<=bestRoute.length;i++){
const ci=cities[bestRoute[i%bestRoute.length]];
i===0?x.moveTo(ci.x,ci.y):x.lineTo(ci.x,ci.y);
}
x.stroke();
// Draw cities
for(let i=0;i<N;i++){
x.fillStyle='#f80';x.beginPath();x.arc(cities[i].x,cities[i].y,4,0,Math.PI*2);x.fill();
}
// Info
x.fillStyle='#aaa';x.font='12px monospace';
x.fillText('TSP — '+N+' cities | Gen: '+gen+' | Best distance: '+bestDist.toFixed(1),20,H-50);
// Mini fitness graph
if(history.length>1){
x.strokeStyle='#4f8';x.lineWidth=1;x.beginPath();
const mn=Math.min(...history),mx=Math.max(...history);
for(let i=0;i<history.length;i++){
const px=20+i/Math.max(history.length-1,1)*(W-40);
const py=H-15-(history[i]-mn)/(mx-mn||1)*25;
i===0?x.moveTo(px,py):x.lineTo(px,py);
}
x.stroke();
}
requestAnimationFrame(draw);
}
draw();
</script></body></html>
Notice how ordered crossover (OX) preserves the relative order of cities from both parents — this is critical for permutation-based problems. Simple point crossover would produce invalid routes with duplicate cities. The algorithm typically finds a route within 10-20% of optimal for 30 cities within a few hundred generations.
4. Color Palette Evolution
Evolve aesthetically pleasing 5-color palettes using a fitness function based on color theory: hue spread, saturation balance, and lightness contrast. Each individual is 5 HSL colors. The algorithm discovers harmonious palettes that a designer might spend hours crafting.
<!DOCTYPE html><html><body style="margin:0;background:#111;color:#eee;font-family:monospace">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const COLORS=5,POP=60,MUTATION_RATE=0.1;
function randomPalette(){return Array.from({length:COLORS},()=>({h:Math.random()*360,s:30+Math.random()*70,l:25+Math.random()*50}))}
function fitness(pal){
// Hue spread: reward evenly distributed hues
const hues=pal.map(c=>c.h).sort((a,b)=>a-b);
let hueScore=0;
for(let i=0;i<COLORS;i++){const gap=(hues[(i+1)%COLORS]-hues[i]+360)%360;hueScore+=Math.min(gap,360/COLORS)/(360/COLORS)}
hueScore/=COLORS;
// Saturation variety
const sats=pal.map(c=>c.s);
const satRange=(Math.max(...sats)-Math.min(...sats))/70;
// Lightness contrast
const lights=pal.map(c=>c.l);
const lightRange=(Math.max(...lights)-Math.min(...lights))/50;
// Penalize very similar colors
let simPenalty=0;
for(let i=0;i<COLORS;i++)for(let j=i+1;j<COLORS;j++){
const dh=Math.min(Math.abs(pal[i].h-pal[j].h),360-Math.abs(pal[i].h-pal[j].h));
const ds=Math.abs(pal[i].s-pal[j].s);const dl=Math.abs(pal[i].l-pal[j].l);
if(dh<15&&ds<10&&dl<10)simPenalty+=0.1;
}
return(hueScore*0.4+satRange*0.3+lightRange*0.3)-simPenalty;
}
function mutate(pal){
return pal.map(c=>{
if(Math.random()<MUTATION_RATE)return{h:(c.h+Math.random()*40-20+360)%360,s:Math.max(10,Math.min(100,c.s+Math.random()*20-10)),l:Math.max(15,Math.min(75,c.l+Math.random()*20-10))};
return{...c};
});
}
function crossover(a,b){return a.map((c,i)=>Math.random()<0.5?{...a[i]}:{...b[i]})}
let pop=Array.from({length:POP},randomPalette),gen=0,bestFit=0;
function step(){
const fits=pop.map(p=>({pal:p,fit:fitness(p)})).sort((a,b)=>b.fit-a.fit);
if(fits[0].fit>bestFit)bestFit=fits[0].fit;
const next=[fits[0].pal.map(c=>({...c}))];
while(next.length<POP){
const pa=fits[Math.random()*POP*0.3|0].pal;
const pb=fits[Math.random()*POP*0.3|0].pal;
next.push(mutate(crossover(pa,pb)));
}
pop=next;gen++;
}
function drawPalette(pal,px,py,w,h){
const sw=w/COLORS;
for(let i=0;i<COLORS;i++){
x.fillStyle=`hsl(${pal[i].h},${pal[i].s}%,${pal[i].l}%)`;
x.beginPath();
x.roundRect(px+i*sw+1,py,sw-2,h,4);x.fill();
}
}
function draw(){
for(let i=0;i<3;i++)step();
x.fillStyle='#111';x.fillRect(0,0,W,H);
x.fillStyle='#aaa';x.font='12px monospace';
x.fillText('Color Palette Evolution — Gen: '+gen,20,25);
// Best palette large
x.fillText('Best palette:',20,50);
drawPalette(pop[0],20,60,W-40,50);
// Top 12 palettes
x.fillStyle='#666';x.fillText('Top 12:',20,135);
const fits=pop.map(p=>({pal:p,fit:fitness(p)})).sort((a,b)=>b.fit-a.fit);
for(let i=0;i<12;i++){
const row=i%3,col=(i/3)|0;
drawPalette(fits[i].pal,20+col*145,150+row*45,130,35);
}
// Color wheel showing best palette
const cx2=W-100,cy2=320,r=60;
x.strokeStyle='#333';x.lineWidth=1;x.beginPath();x.arc(cx2,cy2,r,0,Math.PI*2);x.stroke();
for(const cl of pop[0]){
const angle=cl.h*Math.PI/180;
const dist2=cl.s/100*r;
const px2=cx2+Math.cos(angle)*dist2,py2=cy2+Math.sin(angle)*dist2;
x.fillStyle=`hsl(${cl.h},${cl.s}%,${cl.l}%)`;
x.beginPath();x.arc(px2,py2,6,0,Math.PI*2);x.fill();
x.strokeStyle='#fff';x.lineWidth=1;x.stroke();
}
x.fillStyle='#555';x.fillText('Hue wheel',cx2-25,cy2+r+18);
requestAnimationFrame(draw);
}
draw();
</script></body></html>
The fitness function encodes color theory principles: hue distribution rewards palettes with evenly spaced hues (like complementary or triadic schemes), saturation variety rewards mixing vivid and muted tones, and lightness contrast ensures dark and light colors appear together. The similarity penalty prevents the algorithm from converging on five nearly identical colors.
5. Walking Creatures
Evolve 2D stick-figure creatures that learn to walk. Each creature has jointed limbs controlled by sinusoidal oscillators. The genome encodes amplitude, frequency, and phase for each joint. Fitness is simply horizontal distance traveled. Over generations, creatures evolve gaits from flailing to efficient locomotion.
<!DOCTYPE html><html><body style="margin:0;background:#111;color:#eee;font-family:monospace">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const POP=40,JOINTS=4,GENES=JOINTS*3,SIM_STEPS=300,MUTATION_RATE=0.15;
const GROUND=H-60;
function randomGenes(){return Array.from({length:GENES},()=>Math.random()*2-1)}
function simulate(genes){
let bx=100,by=GROUND-30,vx=0,vy=0;
const limbs=[];
for(let j=0;j<JOINTS;j++)limbs.push({angle:0,len:15+Math.random()*10});
for(let t=0;t<SIM_STEPS;t++){
let fx=0,fy=0;
for(let j=0;j<JOINTS;j++){
const amp=genes[j*3]*0.8,freq=genes[j*3+1]*3,phase=genes[j*3+2]*Math.PI*2;
limbs[j].angle=amp*Math.sin(freq*t*0.05+phase);
const tipX=bx+Math.sin(limbs[j].angle)*limbs[j].len;
const tipY=by+Math.cos(limbs[j].angle)*limbs[j].len;
if(tipY>=GROUND){fx+=Math.sin(limbs[j].angle)*0.5;fy-=0.3}
}
vx=(vx+fx)*0.95;vy=(vy+fy+0.2)*0.95;
bx+=vx;by+=vy;
if(by>GROUND-30)by=GROUND-30;
}
return{dist:bx-100,genes};
}
function crossover(a,b){return a.map((g,i)=>Math.random()<0.5?a[i]:b[i])}
function mutate(genes){return genes.map(g=>Math.random()<MUTATION_RATE?g+Math.random()*0.4-0.2:g)}
let pop=Array.from({length:POP},randomGenes),gen=0,bestDist=0,bestGenes=null,history=[];
function step(){
const results=pop.map(g=>simulate(g)).sort((a,b)=>b.dist-a.dist);
if(results[0].dist>bestDist){bestDist=results[0].dist;bestGenes=[...results[0].genes]}
history.push(bestDist);
const next=[results[0].genes.map(g=>g)];
while(next.length<POP){
const pa=results[Math.random()*POP*0.3|0].genes;
const pb=results[Math.random()*POP*0.3|0].genes;
next.push(mutate(crossover(pa,pb)));
}
pop=next;gen++;
}
let animT=0;
function drawCreature(genes,px,py,t,scale=1){
x.strokeStyle='#4f8';x.lineWidth=2*scale;
// Body
x.beginPath();x.arc(px,py,5*scale,0,Math.PI*2);x.stroke();
// Limbs
for(let j=0;j<JOINTS;j++){
const amp=genes[j*3]*0.8,freq=genes[j*3+1]*3,phase=genes[j*3+2]*Math.PI*2;
const angle=amp*Math.sin(freq*t*0.05+phase);
const side=j<2?-1:1;const yoff=j%2===0?0:8*scale;
const ex=px+Math.sin(angle)*15*scale*side;
const ey=py+yoff+Math.cos(angle)*15*scale;
x.beginPath();x.moveTo(px,py+yoff);x.lineTo(ex,ey);x.stroke();
// Foot
x.fillStyle=ey>=GROUND*scale/1?'#f80':'#4f8';
x.beginPath();x.arc(ex,ey,2*scale,0,Math.PI*2);x.fill();
}
}
function draw(){
step();animT++;
x.fillStyle='#111';x.fillRect(0,0,W,H);
// Ground
x.strokeStyle='#333';x.beginPath();x.moveTo(0,GROUND);x.lineTo(W,GROUND);x.stroke();
// Best creature animated
x.fillStyle='#aaa';x.font='12px monospace';
x.fillText('Walking Creatures — Gen: '+gen+' | Best distance: '+bestDist.toFixed(1)+'px',20,25);
if(bestGenes)drawCreature(bestGenes,150,GROUND-30,animT,1.5);
// Top 8 creatures static
x.fillStyle='#555';x.fillText('Top 8 evolved gaits:',20,GROUND-160);
const results=pop.map(g=>simulate(g)).sort((a,b)=>b.dist-a.dist);
for(let i=0;i<8;i++){
drawCreature(results[i].genes,50+(i%4)*145,GROUND-120+((i/4)|0)*60,animT,0.8);
x.fillStyle='#555';x.font='10px monospace';
x.fillText(results[i].dist.toFixed(0)+'px',40+(i%4)*145,GROUND-80+((i/4)|0)*60);
}
// Fitness graph
if(history.length>1){
x.strokeStyle='#4f8';x.lineWidth=1;x.beginPath();
const mn=Math.min(...history),mx=Math.max(...history);
for(let i=0;i<history.length;i++){
const px=20+i/Math.max(history.length-1,1)*(W-40);
const py=H-10-(history[i]-mn)/((mx-mn)||1)*30;
i===0?x.moveTo(px,py):x.lineTo(px,py);
}
x.stroke();
}
requestAnimationFrame(draw);
}
draw();
</script></body></html>
Each creature's "brain" is just 12 numbers — amplitude, frequency, and phase for each of 4 joints. Yet evolution discovers coordinated gaits where limbs push against the ground in rhythm. This is an example of indirect encoding — the genome does not specify positions directly, but parameters of oscillatory controllers that produce emergent movement patterns.
6. Neuroevolution: Maze Navigation
Neuroevolution evolves neural network weights using a genetic algorithm instead of backpropagation. Here, dots navigate toward a goal while avoiding walls. Each dot has a tiny neural network (5 distance sensors → 4 hidden neurons → 2 outputs for steering). The genome is the flattened weight array. Fitness rewards proximity to the goal and penalizes wall hits.
<!DOCTYPE html><html><body style="margin:0;background:#111;color:#eee;font-family:monospace">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const POP=80,SIM_STEPS=200,MUT_RATE=0.1,MUT_STR=0.3;
const GOAL={x:550,y:225};
const WALLS=[{x:200,y:0,w:20,h:300},{x:350,y:150,w:20,h:300},{x:100,y:200,w:20,h:250}];
function inWall(px,py){for(const w of WALLS)if(px>w.x&&px<w.x+w.w&&py>w.y&&py<w.y+w.h)return true;return px<0||px>W||py<0||py>H}
// Neural net: 5 inputs, 4 hidden, 2 outputs = 5*4+4+4*2+2 = 30 weights
const WEIGHTS=30;
function randomWeights(){return Array.from({length:WEIGHTS},()=>Math.random()*2-1)}
function sigmoid(v){return 1/(1+Math.exp(-v))}
function forward(w,inputs){
const h=[];
for(let j=0;j<4;j++){let s=w[20+j];for(let i=0;i<5;i++)s+=inputs[i]*w[i*4+j];h.push(sigmoid(s))}
const out=[];
for(let j=0;j<2;j++){let s=w[28+j];for(let i=0;i<4;i++)s+=h[i]*w[24+i*2+j];out.push(sigmoid(s))}
return out;
}
function simulate(w){
let px=50,py=225,angle=0,alive=true,minDist=Infinity;
const trail=[];
for(let t=0;t<SIM_STEPS&&alive;t++){
// 5 ray sensors
const sensors=[];
for(let r=-2;r<=2;r++){
const a=angle+r*0.4;let d=0;
while(d<80&&!inWall(px+Math.cos(a)*d,py+Math.sin(a)*d))d+=4;
sensors.push(d/80);
}
const out=forward(w,sensors);
angle+=(out[0]-0.5)*0.3;
const speed=(out[1])*3;
px+=Math.cos(angle)*speed;py+=Math.sin(angle)*speed;
if(inWall(px,py)){alive=false}
const dg=Math.hypot(px-GOAL.x,py-GOAL.y);
if(dg<minDist)minDist=dg;
trail.push({x:px,y:py});
}
return{fitness:1/(1+minDist)+(alive?0.2:0),trail,alive};
}
function mutate(w){return w.map(v=>Math.random()<MUT_RATE?v+Math.random()*MUT_STR*2-MUT_STR:v)}
function crossover(a,b){const pt=Math.random()*WEIGHTS|0;return[...a.slice(0,pt),...b.slice(pt)]}
let pop=Array.from({length:POP},randomWeights),gen=0,bestFit=0,bestTrail=[],history=[];
function step(){
const results=pop.map(w=>({w,res:simulate(w)})).sort((a,b)=>b.res.fitness-a.res.fitness);
if(results[0].res.fitness>bestFit){bestFit=results[0].res.fitness;bestTrail=results[0].res.trail}
history.push(bestFit);
const next=[results[0].w.map(v=>v)];
while(next.length<POP){
const pa=results[Math.random()*POP*0.3|0].w;
const pb=results[Math.random()*POP*0.3|0].w;
next.push(mutate(crossover(pa,pb)));
}
pop=next;gen++;
}
function draw(){
for(let i=0;i<3;i++)step();
x.fillStyle='#111';x.fillRect(0,0,W,H);
// Walls
x.fillStyle='#444';for(const w of WALLS)x.fillRect(w.x,w.y,w.w,w.h);
// Goal
x.fillStyle='#f44';x.beginPath();x.arc(GOAL.x,GOAL.y,8,0,Math.PI*2);x.fill();
x.fillStyle='#f44';x.font='10px monospace';x.fillText('GOAL',GOAL.x-14,GOAL.y-12);
// Start
x.fillStyle='#4af';x.beginPath();x.arc(50,225,5,0,Math.PI*2);x.fill();
// Best trail
if(bestTrail.length>1){
x.strokeStyle='rgba(100,255,150,0.5)';x.lineWidth=2;x.beginPath();
x.moveTo(bestTrail[0].x,bestTrail[0].y);
for(const p of bestTrail)x.lineTo(p.x,p.y);
x.stroke();
}
// Current gen top 5 trails
const results=pop.map(w=>({w,res:simulate(w)})).sort((a,b)=>b.res.fitness-a.res.fitness);
for(let i=0;i<5;i++){
const tr=results[i].res.trail;
if(tr.length<2)continue;
x.strokeStyle=`rgba(100,180,255,${0.3-i*0.05})`;x.lineWidth=1;x.beginPath();
x.moveTo(tr[0].x,tr[0].y);for(const p of tr)x.lineTo(p.x,p.y);x.stroke();
}
// Info
x.fillStyle='#aaa';x.font='12px monospace';
x.fillText('Neuroevolution Maze — Gen: '+gen+' | Best fitness: '+bestFit.toFixed(3),20,20);
x.fillText('Neural net: 5 sensors → 4 hidden → 2 outputs ('+WEIGHTS+' weights)',20,H-10);
requestAnimationFrame(draw);
}
draw();
</script></body></html>
This is NEAT simplified. Real neuroevolution systems (like Kenneth Stanley's NEAT) also evolve the network topology — adding and removing neurons and connections. Our version keeps the architecture fixed and only evolves weights, but the principle is the same: gradient-free optimization of neural networks through evolutionary pressure.
7. Interactive Aesthetic Selection
Not all fitness functions can be written in code. Interactive evolutionary computation (IEC) lets you be the fitness function — you pick your favorites, and the algorithm breeds more like them. Click the patterns you find most beautiful. The algorithm uses your selections as parents for the next generation. This is how Karl Sims evolved virtual creatures and how Picbreeder works.
<!DOCTYPE html><html><body style="margin:0;background:#111;color:#eee;font-family:monospace">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const GRID=3,CELL=Math.min((W-40)/GRID,(H-80)/GRID)|0;
const GENES=12,POP=GRID*GRID,MUT_RATE=0.2;
let selected=[],gen=0;
function randomGenes(){return Array.from({length:GENES},()=>Math.random()*2-1)}
let pop=Array.from({length:POP},randomGenes);
function renderPattern(genes,px,py,size){
const s=size;
for(let yi=0;yi<s;yi++){
for(let xi=0;xi<s;xi++){
const u=xi/s*2-1,v=yi/s*2-1;
const g=genes;
const r=Math.sin(g[0]*u*3+g[1]*v*3+g[2])*0.5+0.5;
const gr=Math.sin(g[3]*u*v*5+g[4]*Math.hypot(u,v)*4+g[5])*0.5+0.5;
const b=Math.sin(g[6]*Math.atan2(v,u)*2+g[7]*Math.hypot(u,v)*3+g[8])*0.5+0.5;
const bri=Math.sin(g[9]*u*u+g[10]*v*v+g[11])*0.3+0.7;
x.fillStyle=`rgb(${r*bri*255|0},${gr*bri*255|0},${b*bri*255|0})`;
x.fillRect(px+xi,py+yi,1,1);
}
}
}
function breed(){
if(selected.length<2)return;
const parents=selected.map(i=>pop[i]);
const next=[];
while(next.length<POP){
const pa=parents[Math.random()*parents.length|0];
const pb=parents[Math.random()*parents.length|0];
const child=pa.map((g,i)=>Math.random()<0.5?pa[i]:pb[i]);
// Mutate
next.push(child.map(g=>Math.random()<MUT_RATE?g+Math.random()*0.5-0.25:g));
}
pop=next;selected=[];gen++;
drawAll();
}
function drawAll(){
x.fillStyle='#111';x.fillRect(0,0,W,H);
x.fillStyle='#aaa';x.font='12px monospace';
x.fillText('Interactive Evolution — Click your favorites, then press SPACE to breed (Gen: '+gen+')',10,20);
x.fillText('Selected: '+selected.length+'/'+POP+' — need at least 2',10,38);
const ox=(W-GRID*CELL-(GRID-1)*8)/2,oy=50;
for(let i=0;i<POP;i++){
const col=i%GRID,row=(i/GRID)|0;
const px=ox+col*(CELL+8),py=oy+row*(CELL+8);
renderPattern(pop[i],px,py,CELL);
if(selected.includes(i)){
x.strokeStyle='#4f8';x.lineWidth=3;x.strokeRect(px-2,py-2,CELL+4,CELL+4);
}
}
}
c.addEventListener('click',e=>{
const rect=c.getBoundingClientRect();
const mx=(e.clientX-rect.left)*(W/rect.width);
const my=(e.clientY-rect.top)*(H/rect.height);
const ox=(W-GRID*CELL-(GRID-1)*8)/2,oy=50;
for(let i=0;i<POP;i++){
const col=i%GRID,row=(i/GRID)|0;
const px=ox+col*(CELL+8),py=oy+row*(CELL+8);
if(mx>px&&mx<px+CELL&&my>py&&my<py+CELL){
const idx=selected.indexOf(i);
if(idx>-1)selected.splice(idx,1);else selected.push(i);
drawAll();break;
}
}
});
document.addEventListener('keydown',e=>{if(e.code==='Space'){e.preventDefault();breed()}});
drawAll();
</script></body></html>
This technique is called aesthetic selection or interactive evolutionary computation (IEC). The patterns are generated by Compositional Pattern-Producing Networks (CPPNs) — functions that map (x, y) coordinates to colors using sine, cosine, and hyperbolic functions. Each gene controls a coefficient in these functions. By selecting your favorites and breeding them, you guide evolution through a space of visual forms that no fitness function could capture.
8. Generative Art Evolution
The grand finale: evolve complete generative artworks. Each individual is a parameter set controlling multiple visual layers — particle counts, colors, shapes, speeds, and compositions. Fitness is automated using a multi-criteria function that rewards visual complexity, color harmony, and spatial balance. Watch as the algorithm discovers compositions that are both structured and surprising.
<!DOCTYPE html><html><body style="margin:0;background:#111;color:#eee;font-family:monospace">
<canvas id="c"></canvas>
<script>
const c=document.getElementById('c'),x=c.getContext('2d');
const W=c.width=600,H=c.height=450;
const POP=20,GENES=20,MUT_RATE=0.12;
const RS=100;// render size
function randomGenes(){return Array.from({length:GENES},()=>Math.random())}
function renderArt(g,ctx,s){
ctx.fillStyle=`hsl(${g[0]*360},${20+g[1]*30}%,${5+g[2]*10}%)`;
ctx.fillRect(0,0,s,s);
const layers=(g[3]*4|0)+2;
for(let l=0;l<layers;l++){
const gi=((l*3+4)%GENES);
const hue=(g[gi%GENES]*360+l*60)%360;
const count=(g[(gi+1)%GENES]*30|0)+5;
const size=g[(gi+2)%GENES]*s*0.3+s*0.02;
const shape=g[(gi+3)%GENES];
ctx.globalAlpha=0.3+g[(gi+4)%GENES]*0.5;
ctx.fillStyle=`hsl(${hue},${50+g[(gi+5)%GENES]*40}%,${40+g[(gi+6)%GENES]*30}%)`;
for(let i=0;i<count;i++){
const angle=g[(gi+7)%GENES]*Math.PI*2+i/count*Math.PI*2;
const radius=g[(gi+8)%GENES]*s*0.3+l*s*0.05;
const px=s/2+Math.cos(angle)*radius;
const py=s/2+Math.sin(angle)*radius;
ctx.beginPath();
if(shape<0.33){ctx.arc(px,py,size/2,0,Math.PI*2)}
else if(shape<0.66){ctx.rect(px-size/2,py-size/2,size,size)}
else{ctx.moveTo(px,py-size/2);ctx.lineTo(px+size/2,py+size/2);ctx.lineTo(px-size/2,py+size/2);ctx.closePath()}
ctx.fill();
}
}
ctx.globalAlpha=1;
}
function fitness(g){
const fc=document.createElement('canvas');fc.width=fc.height=RS;const fx=fc.getContext('2d');
renderArt(g,fx,RS);
const d=fx.getImageData(0,0,RS,RS).data;
// Color variety
const hueSet=new Set();
for(let i=0;i<d.length;i+=16){
const r=d[i],g2=d[i+1],b=d[i+2];
const max=Math.max(r,g2,b),min=Math.min(r,g2,b);
if(max-min>20){let h=0;if(max===r)h=((g2-b)/(max-min))%6;else if(max===g2)h=(b-r)/(max-min)+2;else h=(r-g2)/(max-min)+4;hueSet.add((h*60+360)%360|0/30)}
}
const colorScore=Math.min(hueSet.size/8,1);
// Spatial balance (compare quadrant brightness)
let quads=[0,0,0,0],qc=[0,0,0,0];
for(let y=0;y<RS;y++)for(let xp=0;xp<RS;xp++){
const i=(y*RS+xp)*4;const bri=(d[i]+d[i+1]+d[i+2])/3;
const q=(xp<RS/2?0:1)+(y<RS/2?0:2);quads[q]+=bri;qc[q]++;
}
quads=quads.map((v,i)=>v/qc[i]);
const avgBri=quads.reduce((a,b)=>a+b)/4;
const balanceScore=1-quads.reduce((s,q)=>s+Math.abs(q-avgBri),0)/(avgBri*4+1);
// Complexity (edge count via brightness gradient)
let edges=0;
for(let y=1;y<RS-1;y++)for(let xp=1;xp<RS-1;xp++){
const i=(y*RS+xp)*4;const i2=(y*RS+xp+1)*4;const i3=((y+1)*RS+xp)*4;
const dx=Math.abs(d[i]-d[i2])+Math.abs(d[i+1]-d[i2+1])+Math.abs(d[i+2]-d[i2+2]);
const dy=Math.abs(d[i]-d[i3])+Math.abs(d[i+1]-d[i3+1])+Math.abs(d[i+2]-d[i3+2]);
if(dx+dy>80)edges++;
}
const complexityScore=Math.min(edges/(RS*RS*0.15),1);
return colorScore*0.35+balanceScore*0.3+complexityScore*0.35;
}
function mutate(g){return g.map(v=>Math.random()<MUT_RATE?Math.max(0,Math.min(1,v+Math.random()*0.3-0.15)):v)}
function crossover(a,b){return a.map((v,i)=>Math.random()<0.5?a[i]:b[i])}
let pop=Array.from({length:POP},randomGenes),gen=0,bestFit=0;
function step(){
const results=pop.map(g=>({g,fit:fitness(g)})).sort((a,b)=>b.fit-a.fit);
if(results[0].fit>bestFit)bestFit=results[0].fit;
const next=[results[0].g.map(v=>v)];
while(next.length<POP){
const pa=results[Math.random()*POP*0.3|0].g;
const pb=results[Math.random()*POP*0.3|0].g;
next.push(mutate(crossover(pa,pb)));
}
pop=next;gen++;
}
function draw(){
step();
x.fillStyle='#111';x.fillRect(0,0,W,H);
x.fillStyle='#aaa';x.font='12px monospace';
x.fillText('Generative Art Evolution — Gen: '+gen+' | Best: '+(bestFit*100).toFixed(1)+'%',20,20);
// Best artwork large
const bc=document.createElement('canvas');bc.width=bc.height=RS;const bx=bc.getContext('2d');
renderArt(pop[0],bx,RS);
x.drawImage(bc,20,35,180,180);
x.fillStyle='#4f8';x.fillText('Best',20,230);
// Top 9 grid
const results=pop.map(g=>({g,fit:fitness(g)})).sort((a,b)=>b.fit-a.fit);
for(let i=0;i<9;i++){
const rc=document.createElement('canvas');rc.width=rc.height=RS;const rx=rc.getContext('2d');
renderArt(results[i].g,rx,RS);
const col=i%3,row=(i/3)|0;
x.drawImage(rc,220+col*130,35+row*140,120,120);
x.fillStyle='#555';x.font='10px monospace';
x.fillText((results[i].fit*100).toFixed(0)+'%',220+col*130,170+row*140);
}
requestAnimationFrame(draw);
}
draw();
</script></body></html>
The automated fitness function combines three aesthetic criteria: color variety (hue distribution across the image), spatial balance (even brightness distribution across quadrants), and visual complexity (edge density measured by pixel gradient magnitude). These proxy measures don't capture everything about aesthetics, but they push evolution away from boring monochrome blobs and toward structured, colorful compositions.
How Genetic Algorithms Work: The Core Loop
Every genetic algorithm follows the same five-step loop:
- Initialize — create a random population of candidate solutions
- Evaluate — measure each individual's fitness (how good is this solution?)
- Select — choose parents based on fitness (better solutions breed more)
- Crossover — combine genes from two parents to create offspring
- Mutate — introduce small random changes to maintain diversity
Repeat steps 2-5 for hundreds or thousands of generations. The population converges toward high-fitness solutions through the combined effect of selection pressure (favoring good solutions), crossover (combining good traits), and mutation (exploring new possibilities).
Key Parameters and Their Effects
- Population size — larger populations explore more of the search space but are slower per generation. 50-200 is typical for most problems
- Mutation rate — too low and the population converges prematurely on a local optimum; too high and good solutions are destroyed faster than they can spread. 1-10% per gene is a common range
- Selection pressure — tournament selection with tournament size 2-7 works well. Higher pressure converges faster but risks losing diversity
- Elitism — always copying the best individual(s) to the next generation prevents losing the best solution found so far
- Crossover method — single-point for bit strings, ordered crossover (OX) for permutations, uniform crossover for continuous parameters
Performance Tips
- Cache fitness evaluations — if the fitness function is expensive (like pixel comparison), avoid re-evaluating unchanged individuals
- Use Web Workers for parallel fitness evaluation — each individual can be evaluated independently
- Adaptive mutation — start with high mutation rate and decrease as the population converges
- Island model — run multiple sub-populations in parallel with occasional migration between them to maintain diversity
- Keep render size small for pixel-based fitness (64×64 or 128×128) — you only need enough resolution to distinguish good from bad
Want to explore more algorithm-driven art? Visit Lumitree to discover unique generative micro-worlds, or browse the full collection of creative coding tutorials for more visual programming guides.