Genetic Algorithm
Selection, crossover, mutation - repeat.
Intermediate / optimization / evolution / search
A population of candidates lives on a rugged fitness landscape. Each generation brings tournament selection, blend crossover, gaussian mutation, and a few elites carried through unchanged. No gradient exists here - the algorithm only ever compares fitness values, which is exactly what makes GAs fit landscapes that break gradient methods.
Tagged: tournament selection, crossover, mutation, elitism
Live simulation
The dots are the population climbing the landscape; accent marks the current best. Keyboard: Space runs or pauses, . steps, r resets.
Speed
The dots are the population climbing the landscape; accent marks the current best.
Mathematics
A genetic algorithm searches by evolving a population: candidates with higher fitness are selected as parents, traits are blended by crossover, and mutation perturbs the result. No derivatives - selection pressure is the only gradient-like signal.
selection: k-way tournament (pick k random, keep the fittest)
crossover: child = alpha * parent_a + (1 - alpha) * parent_b
mutation: child += N(0, sigma_mutation); elites carried unchanged
Elitism guarantees the best fitness never decreases, which the history strip shows as a monotone ceiling over a noisy population mean. Raising the mutation sigma explores more but commits less - the classic exploration-exploitation trade-off, one slider wide.
How it works
The landscape is a fixed sum of four Gaussian peaks plus a mild ripple, evaluated at each candidate's (x, y). Each tick runs a configurable number of generations: rank, copy elites, breed the rest by tournament and blend crossover, mutate, clamp to the unit square.
The heatmap is the landscape itself; the population dots are drawn on top, with the current best highlighted in accent.
Implementation
src/engine/experiments/genetic-algorithm.ts · 204 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | /** |
| 5 | * Genetic algorithm optimizing over a rugged 2-D landscape: |
| 6 | * tournament selection, blend crossover, gaussian mutation, elitism. |
| 7 | * The population dots on the heatmap ARE the algorithm's state. |
| 8 | */ |
| 9 | export class GeneticAlgorithmExperiment extends BaseExperiment { |
| 10 | readonly id = "genetic-algorithm"; |
| 11 | |
| 12 | private popX = new Float64Array(0); |
| 13 | private popY = new Float64Array(0); |
| 14 | private fitness = new Float64Array(0); |
| 15 | private popSize = 0; |
| 16 | private generation = 0; |
| 17 | private bestFitness = 0; |
| 18 | private meanFitness = 0; |
| 19 | private history: number[] = []; |
| 20 | |
| 21 | protected params(): ParameterDef[] { |
| 22 | return [ |
| 23 | { key: "population", label: "Population", min: 20, max: 200, step: 10, defaultValue: 80 }, |
| 24 | { key: "mutation", label: "Mutation sigma", min: 0.005, max: 0.15, step: 0.005, defaultValue: 0.04 }, |
| 25 | { key: "elitism", label: "Elites kept", min: 1, max: 10, step: 1, defaultValue: 3 }, |
| 26 | { key: "generations", label: "Generations per tick", min: 1, max: 10, step: 1, defaultValue: 1 }, |
| 27 | ]; |
| 28 | } |
| 29 | |
| 30 | /** Rugged multi-peak landscape in [0,1]^2. */ |
| 31 | private landscape(x: number, y: number): number { |
| 32 | const peak = (cx: number, cy: number, s: number, amp: number) => |
| 33 | amp * Math.exp(-(((x - cx) ** 2 + (y - cy) ** 2) / (2 * s * s))); |
| 34 | return ( |
| 35 | peak(0.28, 0.3, 0.09, 1) + |
| 36 | peak(0.72, 0.62, 0.12, 0.86) + |
| 37 | peak(0.6, 0.2, 0.05, 0.6) + |
| 38 | peak(0.2, 0.78, 0.07, 0.5) + |
| 39 | 0.12 * Math.sin(x * 21) * Math.cos(y * 18) |
| 40 | ); |
| 41 | } |
| 42 | |
| 43 | protected onReset(): void { |
| 44 | this.popSize = this.num("population"); |
| 45 | this.popX = new Float64Array(this.popSize); |
| 46 | this.popY = new Float64Array(this.popSize); |
| 47 | this.fitness = new Float64Array(this.popSize); |
| 48 | for (let i = 0; i < this.popSize; i++) { |
| 49 | this.popX[i] = this.rng(); |
| 50 | this.popY[i] = this.rng(); |
| 51 | this.fitness[i] = this.landscape(this.popX[i], this.popY[i]); |
| 52 | } |
| 53 | this.generation = 0; |
| 54 | this.history = []; |
| 55 | this.evaluate(); |
| 56 | } |
| 57 | |
| 58 | protected onParameterChange(key: string): void { |
| 59 | if (key !== "generations") this.reset(); |
| 60 | } |
| 61 | |
| 62 | resize(width: number, height: number): void { |
| 63 | this.width = width; |
| 64 | this.height = height; |
| 65 | } |
| 66 | |
| 67 | private evaluate(): void { |
| 68 | let best = -Infinity; |
| 69 | let sum = 0; |
| 70 | for (let i = 0; i < this.popSize; i++) { |
| 71 | this.fitness[i] = this.landscape(this.popX[i], this.popY[i]); |
| 72 | best = Math.max(best, this.fitness[i]); |
| 73 | sum += this.fitness[i]; |
| 74 | } |
| 75 | this.bestFitness = best; |
| 76 | this.meanFitness = sum / this.popSize; |
| 77 | } |
| 78 | |
| 79 | private tournament(k = 3): number { |
| 80 | let best = Math.floor(this.rng() * this.popSize); |
| 81 | for (let i = 1; i < k; i++) { |
| 82 | const challenger = Math.floor(this.rng() * this.popSize); |
| 83 | if (this.fitness[challenger] > this.fitness[best]) best = challenger; |
| 84 | } |
| 85 | return best; |
| 86 | } |
| 87 | |
| 88 | protected onUpdate(): void { |
| 89 | const elites = Math.min(this.num("elitism"), this.popSize - 1); |
| 90 | const sigma = this.num("mutation"); |
| 91 | const generations = this.num("generations"); |
| 92 | for (let g = 0; g < generations; g++) { |
| 93 | // Rank indices by fitness. |
| 94 | const order = Array.from({ length: this.popSize }, (_, i) => i); |
| 95 | order.sort((a, b) => this.fitness[b] - this.fitness[a]); |
| 96 | const nextX = new Float64Array(this.popSize); |
| 97 | const nextY = new Float64Array(this.popSize); |
| 98 | // Elitism: carry the best unchanged. |
| 99 | for (let e = 0; e < elites; e++) { |
| 100 | nextX[e] = this.popX[order[e]]; |
| 101 | nextY[e] = this.popY[order[e]]; |
| 102 | } |
| 103 | for (let i = elites; i < this.popSize; i++) { |
| 104 | const pa = this.tournament(); |
| 105 | const pb = this.tournament(); |
| 106 | // Blend crossover. |
| 107 | const alpha = this.rng(); |
| 108 | let x = alpha * this.popX[pa] + (1 - alpha) * this.popX[pb]; |
| 109 | let y = alpha * this.popY[pa] + (1 - alpha) * this.popY[pb]; |
| 110 | // Gaussian mutation. |
| 111 | x += (this.rng() + this.rng() + this.rng() - 1.5) * sigma; |
| 112 | y += (this.rng() + this.rng() + this.rng() - 1.5) * sigma; |
| 113 | nextX[i] = Math.min(1, Math.max(0, x)); |
| 114 | nextY[i] = Math.min(1, Math.max(0, y)); |
| 115 | } |
| 116 | this.popX = nextX; |
| 117 | this.popY = nextY; |
| 118 | this.generation += 1; |
| 119 | this.evaluate(); |
| 120 | this.history.push(this.bestFitness); |
| 121 | if (this.history.length > 400) this.history.shift(); |
| 122 | } |
| 123 | } |
| 124 | |
| 125 | onPointer(): void {} |
| 126 | |
| 127 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 128 | ctx.fillStyle = theme.bg; |
| 129 | ctx.fillRect(0, 0, this.width, this.height); |
| 130 | // Landscape heatmap (low-res blocks) |
| 131 | const cells = 44; |
| 132 | const cw = this.width / cells; |
| 133 | const ch = this.height / cells; |
| 134 | let min = Infinity; |
| 135 | let max = -Infinity; |
| 136 | const grid = new Float64Array(cells * cells); |
| 137 | for (let gy = 0; gy < cells; gy++) { |
| 138 | for (let gx = 0; gx < cells; gx++) { |
| 139 | const value = this.landscape((gx + 0.5) / cells, (gy + 0.5) / cells); |
| 140 | grid[gy * cells + gx] = value; |
| 141 | min = Math.min(min, value); |
| 142 | max = Math.max(max, value); |
| 143 | } |
| 144 | } |
| 145 | for (let gy = 0; gy < cells; gy++) { |
| 146 | for (let gx = 0; gx < cells; gx++) { |
| 147 | const t = (grid[gy * cells + gx] - min) / (max - min); |
| 148 | const alpha = 0.06 + t * 0.3; |
| 149 | ctx.fillStyle = t > 0.72 ? theme.accent : theme.fgSecondary; |
| 150 | ctx.globalAlpha = alpha; |
| 151 | ctx.fillRect(gx * cw, gy * ch, cw + 0.5, ch + 0.5); |
| 152 | } |
| 153 | } |
| 154 | ctx.globalAlpha = 1; |
| 155 | // Population |
| 156 | for (let i = 0; i < this.popSize; i++) { |
| 157 | const rank = this.fitness[i] >= this.bestFitness - 1e-9; |
| 158 | ctx.fillStyle = rank ? theme.accent : theme.fg; |
| 159 | ctx.beginPath(); |
| 160 | ctx.arc(this.popX[i] * this.width, this.popY[i] * this.height, rank ? 3.6 : 2.2, 0, Math.PI * 2); |
| 161 | ctx.fill(); |
| 162 | } |
| 163 | // Best-fitness history (bottom strip) |
| 164 | if (this.history.length > 2) { |
| 165 | ctx.strokeStyle = theme.viz[1]; |
| 166 | ctx.lineWidth = 1.5; |
| 167 | ctx.globalAlpha = 0.8; |
| 168 | ctx.beginPath(); |
| 169 | let lo = Infinity; |
| 170 | let hi = -Infinity; |
| 171 | for (const h of this.history) { |
| 172 | lo = Math.min(lo, h); |
| 173 | hi = Math.max(hi, h); |
| 174 | } |
| 175 | const span = Math.max(1e-6, hi - lo); |
| 176 | for (let i = 0; i < this.history.length; i++) { |
| 177 | const x = (i / (this.history.length - 1)) * this.width; |
| 178 | const y = this.height - 8 - ((this.history[i] - lo) / span) * 26; |
| 179 | if (i === 0) ctx.moveTo(x, y); |
| 180 | else ctx.lineTo(x, y); |
| 181 | } |
| 182 | ctx.stroke(); |
| 183 | ctx.globalAlpha = 1; |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | getMetrics() { |
| 188 | return { |
| 189 | generation: this.generation, |
| 190 | best: this.bestFitness.toFixed(4), |
| 191 | mean: this.meanFitness.toFixed(4), |
| 192 | mutation: this.num("mutation").toFixed(3), |
| 193 | }; |
| 194 | } |
| 195 | |
| 196 | describe(): string { |
| 197 | return `Generation ${this.generation}: the best of ${this.popSize} candidates reaches fitness ${this.bestFitness.toFixed(3)} on the rugged landscape.`; |
| 198 | } |
| 199 | |
| 200 | entities(): number { |
| 201 | return this.popSize; |
| 202 | } |
| 203 | } |
| 204 | |
Performance
- Time per tick
- O(population) fitness evaluations per generation
- Space
- Two population arrays plus a history buffer
- Notes
- Drop mutation to zero after a few generations and the population collapses onto its elites - premature convergence, live.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.