Reinforcement learning, evolutionary algorithms, and anything that lets computers see are some of the most visually satisfying problems to work on. Watching something learn in real time never gets old.
This article is really just an excuse to show off a tiny graphics renderer I built for Node.js: tessera.js.
And I honestly can’t think of a better demo than the hello world of evolutionary algorithms:
a bunch of random colors mutating and slowly evolving toward a target color.
If you’re new to graphics programming, don’t worry, the unit of everything visual in the digital world is surprisingly simple. I cover that here:
Graphics 101: A Node.js Renderer
Finding the Target Color
Install tessera:
npm i tessera.js@latest
Start the renderer:
import { loadRenderer, PixelBuffer, ShapeDrawer } from "tessera.js";
const { Renderer } = loadRenderer();
const renderer = new Renderer();
if (!renderer.initialize(1200, 800, "Evolution Algos Hello World")) {
console.error("failed to init renderer");
process.exit(1);
}
renderer.targetFPS = 60;
const canvas = new PixelBuffer(renderer, 1200, 800);
Parameters and the Genome (the “species”)
// Target color we're evolving toward
const TARGET = { r: 120, g: 80, b: 150 };
/**
* Genome = full DNA
* Genotype = specific gene values
* Phenotype = what we actually see
*
* Here: genome is just RGB.
*/
class Genome {
constructor(r = null, g = null, b = null) {
this.r = r ?? Math.floor(Math.random() * 256);
this.g = g ?? Math.floor(Math.random() * 256);
this.b = b ?? Math.floor(Math.random() * 256);
this.fitness = 0;
}
}
const MUTATION_RATE = 0.3; // more chaos
const ELITE_COUNT = 5; // more stability
const POP_SIZE = 150; // slower but more thorough
let population = Array.from({ length: POP_SIZE }, () => new Genome());
let generation = 0;
let bestEver = null;
The Core Evolution Algorithm
// Fitness: lower distance = better
function evaluateFitness(genome) {
const dr = genome.r - TARGET.r;
const dg = genome.g - TARGET.g;
const db = genome.b - TARGET.b;
genome.fitness = Math.sqrt(dr * dr + dg * dg + db * db);
}
// Tournament selection
function selectParent(pop) {
const tournamentSize = 3;
let best = pop[Math.floor(Math.random() * pop.length)];
for (let i = 1; i < tournamentSize; i++) {
const candidate = pop[Math.floor(Math.random() * pop.length)];
if (candidate.fitness < best.fitness) best = candidate;
}
return best;
}
// Crossover: mix genes
function crossover(p1, p2) {
const child = new Genome();
child.r = Math.random() < 0.5 ? p1.r : p2.r;
child.g = Math.random() < 0.5 ? p1.g : p2.g;
child.b = Math.random() < 0.5 ? p1.b : p2.b;
return child;
}
function randomGaussian() {
return Math.sqrt(-2 * Math.log(Math.random())) *
Math.cos(2 * Math.PI * Math.random());
}
// Mutation
function mutate(genome) {
if (Math.random() < MUTATION_RATE)
genome.r = Math.max(0, Math.min(255, genome.r + randomGaussian() * 60));
if (Math.random() < MUTATION_RATE)
genome.g = Math.max(0, Math.min(255, genome.g + randomGaussian() * 60));
if (Math.random() < MUTATION_RATE)
genome.b = Math.max(0, Math.min(255, genome.b + randomGaussian() * 60));
}
Tie Everything Together
function evolve() {
population.forEach(evaluateFitness);
population.sort((a, b) => a.fitness - b.fitness);
if (!bestEver || population[0].fitness < bestEver.fitness) {
bestEver = { ...population[0] };
}
const newPop = [];
// Elitism
for (let i = 0; i < ELITE_COUNT; i++) {
newPop.push(new Genome(
population[i].r,
population[i].g,
population[i].b
));
}
while (newPop.length < POP_SIZE) {
const p1 = selectParent(population);
const p2 = selectParent(population);
const child = crossover(p1, p2);
mutate(child);
newPop.push(child);
}
population = newPop;
generation++;
}
Render Everything
function render() {
canvas.clear(20, 20, 25, 255);
// Target
ShapeDrawer.fillRect(canvas, 50, 50, 150, 150, TARGET.r, TARGET.g, TARGET.b, 255);
// Best current
const best = population[0];
ShapeDrawer.fillRect(canvas, 250, 50, 150, 150, best.r, best.g, best.b, 255);
// Best ever
if (bestEver) {
ShapeDrawer.fillRect(canvas, 450, 50, 150, 150, bestEver.r, bestEver.g, bestEver.b, 255);
}
// Population grid
const gridX = 50, gridY = 250, cellSize = 20, cols = 10;
population.forEach((g, i) => {
const x = gridX + (i % cols) * (cellSize + 5);
const y = gridY + Math.floor(i / cols) * (cellSize + 5);
ShapeDrawer.fillRect(canvas, x, y, cellSize, cellSize, g.r, g.g, g.b, 255);
});
canvas.upload();
}
renderer.onRender(() => {
canvas.draw(0, 0);
renderer.drawText("TARGET", { x: 50, y: 30 }, 16, { r: 1, g: 1, b: 1, a: 1 });
renderer.drawText("BEST NOW", { x: 250, y: 30 }, 16, { r: 1, g: 1, b: 1, a: 1 });
renderer.drawText("BEST EVER", { x: 450, y: 30 }, 16, { r: 1, g: 1, b: 1, a: 1 });
const best = population[0];
renderer.drawText(
`Gen: ${generation} | Fitness: ${best.fitness.toFixed(2)} | RGB: (${best.r}, ${best.g}, ${best.b})`,
{ x: 50, y: 650 },
16,
{ r: 1, g: 1, b: 1, a: 1 }
);
});
The Loop
let frameCount = 0;
const FRAME_DELAY = 60;
evolve();
function Loop() {
frameCount++;
renderer.input.GetInput();
if (frameCount % FRAME_DELAY === 0) {
evolve();
}
render();
if (renderer.step()) {
setImmediate(Loop);
} else {
renderer.shutdown();
}
}
Loop();
In ~100 lines (minus boilerplate), we’ve got a fully animated evolutionary algorithm running in Node.js.
If graphics programming is something you’re curious about, I’ve got more content coming.
Check out the repo:
https://github.com/sklyt/render
Star it and browse the examples if this kind of stuff is your thing.
More from me:
Building A Distributed Video Transcoding System with Node.js
Thanks for reading!
Find me here:

Top comments (0)