Artificial Life Lab: Evolve a Digital Ecosystem in Your Browser

By 24 min read

Quick Answer

This is a small, repeatable computer model of inheritance and survival. Digital organisms spend energy, search for food, reproduce, pass traits to offspring with mutation, and die. Their population changes without a script deciding which lineage must win.

Key Terms

  • Artificial life
  • Genome
  • Inheritance
  • Mutation
  • Selection pressure
  • Fitness
  • Genetic drift
  • Carrying capacity
  • Emergence
  • Deterministic seed

Are the digital organisms alive or conscious?

No. They are data records updated by explicit rules. The model can demonstrate variation, inheritance, selection, drift, and energy constraints without implying sensation, intention, or biological completeness.

Why does the same seed sometimes seem to produce the same history?

All random choices come from a seeded pseudo-random number generator, while the engine advances in fixed time steps. Identical seeds, parameters, and fixed-step sequences reproduce the same engine history. Real-time runs may diverge when actions occur at different simulation times or a browser drops catch-up time.

Does Advance 8 simulated seconds correspond to one biological generation?

No. Generations overlap in this model. The button advances exactly 240 fixed simulation ticks, during which zero, one, or many births may occur.

What does fitness mean here?

Fitness means leaving descendants that survive under the current conditions. It is not a moral score, strength score, or universal ranking; a useful trait in one preset may be costly in another.

Audio article

Uses the speech voice supplied by your browser or device.

Original abstract microbes with translucent membranes moving through a dark petri dish beneath the words Artificial Life Lab and Evolving Microbe Garden
A garden whose history is calculated rather than illustrated. The organisms below use original procedural shapes; the poster is the static fallback.

A pond can look still while being furiously negotiated. Something is eating. Something is failing to find food. Something has just divided. A useful variation has appeared, although no witness can know whether it will spread or vanish in the next unlucky minute.

The Evolving Microbe Garden is a deliberately small version of that kind of history. Every coloured microbe is a record containing position, energy, age, ancestry, and an inheritable genome. No animation path tells it where to go. No plot declares which colour must win. The visible population comes from organisms following the same local rules under shared environmental pressure.

Start by letting the default garden run. Then try Scarcity and Competition and watch average energy and population. Turn on Highlight deepest surviving lineage. Finally, load Predator–Prey Arms Race and compare movement speed and sensing over several fixed-time advances.

Artificial Life Lab · Series 01

Evolving Microbe Garden

Static poster for the Evolving Microbe Garden
gulp bump birth hunted

Live census

Population telemetry

0 fps

Population
0
Births
0
Deaths
0
Generation
0
Average energy
0.0 E
Average speed
0.0 u/s
Average size
0.0 u
Average sensing
0.0 u
Average mutation
0.0%
Deepest surviving lineage
No extant lineage
Dominant phenotype
No dominant phenotype
Food available
0
Predators
0
Simulated time
0 s
Population over time 0
Population over timeThe line shows the living population sampled during the current run.
Cumulative births / deaths 0 / 0
Cumulative births and deathsCyan is cumulative births; amber is cumulative deaths.
Movement speed distributionEach bar counts living organisms whose inherited trait falls in that range.

This advances exactly 240 fixed ticks. Generations overlap, so zero, one, or many births may occur.

Preparing the habitat…

Experimental conditions

Shape the selection pressure

Scientific presets

World
Energy economy
Evolution and pressure
Six ideas the controls expose
Selection pressure
Conditions change which inherited variants leave descendants.
Mutation
Random bounded variation enters when offspring inherit a genome.
Inheritance
Offspring begin with their parent's traits before mutation.
Fitness
Here it means leaving surviving offspring, not strength or worth.
Drift
Finite populations let chance change trait frequencies.
Carrying capacity
Food, energy costs, competition, and mortality shape an emergent sustainable population. The separate population cap is only a browser safety limit.

Pauses the live run and advances exactly 240 fixed simulation ticks, equal to 8 simulated seconds.

Every body is a visible phenotype: speed lengthens the cell and its flagella, sensing adds cilia, avoidance grows armour, efficiency adds internal vacuoles, and mutation pressure adds membrane speckles. Gold gulps mark feeding, cyan ripples mark collisions, bright rings mark births, and coral hunters visibly swallow prey. This is an explanatory model, not a claim that the organisms are conscious or biologically complete.

What this model is—and is not

This is an artificial-life model: a computational system designed to investigate life-like processes from simple rules. It contains inheritance, bounded mutation, differential reproduction, limited resources, finite lifespans, chance, and death. Those ingredients are enough for population-level patterns to emerge without being scheduled.

The microbes are not alive, sentient, frightened, hungry, or purposeful. Words such as “sense,” “seek,” and “avoid” are convenient descriptions of calculations. A microbe does not experience a predator. Its heading is adjusted away from the nearest predator inside a numerical radius.

Nor does the garden reproduce biological evolution in full. Real organisms contain developmental processes, molecular networks, sexual recombination, ecological relationships, spatial structure, pathogens, changing environments, and historical contingencies that this model omits. Its genome is eleven floating-point numbers. Its world is one ellipse. It is a conceptual instrument, not a biological prediction machine.

Within those limits, it makes six important ideas visible:

  • Inheritance: offspring begin with a copy of a parent’s traits.
  • Variation: mutation sometimes alters those copied values.
  • Selection pressure: food, energy costs, predators, harshness, and lifespan change which variants leave descendants.
  • Fitness: success means producing descendants that persist in this environment, not being universally superior.
  • Drift: in a finite population, chance alone can make a lineage common or extinct.
  • Carrying capacity: food, energy costs, competition, and mortality shape an emergent sustainable population. The separate population cap is only a browser safety limit.

The genome: phenotype packed into eleven numbers

The visible membrane is not merely decoration. Every inherited number now has a readable visual echo. Speed makes the body more streamlined and grows additional flagella; sensory radius adds longer, denser cilia; danger avoidance grows defensive spines; efficiency fills the cell with more vacuoles; reproduction threshold changes the nucleus; turning tendency bends the silhouette; food attraction enlarges the feeding groove; and mutation rate increases coloured membrane speckles. These are honest visual encodings of the model’s numbers, not claims that real microbes express those traits in exactly this way.

type Genome = {
  movementSpeed: number;
  bodySize: number;
  sensoryRadius: number;
  turningTendency: number;
  energyEfficiency: number;
  reproductionThreshold: number;
  mutationRate: number;
  lifespan: number;
  foodAttraction: number;
  dangerAvoidance: number;
  hue: number;
};

Traits create trade-offs. A fast microbe reaches distant food sooner but pays a larger movement bill. A large body is easy to see and may cover food more readily, but moving it costs more. A broad sensory radius supplies better information, yet information only helps if movement towards the detected food is affordable. A low reproduction threshold can produce descendants quickly while dividing limited energy into fragile portions.

There is no single best genome. Change the environment and “best” changes with it.

Keeping mutation bounded

Mutation should create novelty without producing nonsense such as negative body size or a lifespan of minus twelve seconds. Each gene therefore has an explicit interval.

const GENOME_BOUNDS = {
  movementSpeed: [24, 88],
  bodySize: [3.8, 12],
  sensoryRadius: [24, 190],
  mutationRate: [0.02, 0.5],
  hue: [0, 360]
  // the remaining traits have bounds too
};

Offspring copy every parental value. Baseline mutation probability is the environment-wide starting chance for each gene. The parent’s inherited mutationRate gene scales that baseline: a parent with mutationRate 0.08 halves the default multiplier, while 0.24 multiplies it by 1.5. The multiplier is bounded between 0.35 and 2.5, and the final per-gene probability is capped at 95 per cent.

const inheritedMutationPressure = clamp(
  parent.mutationRate / 0.16,
  0.35,
  2.5
);

const perGeneProbability = clamp(
  parameters.mutationProbability * inheritedMutationPressure,
  0,
  0.95
);

for (const key of genomeKeys) {
  const [minimum, maximum] = GENOME_BOUNDS[key];
  let value = parent[key];
  if (random.chance(perGeneProbability)) {
    value += random.signed() * (maximum - minimum) * mutationMagnitude;
  }
  child[key] = clamp(value, minimum, maximum);
}

When mutation occurs, it adds or subtracts a fraction of that gene’s permitted range, scaled by Mutation magnitude. The final value is clamped into bounds. The baseline control therefore does not promise one universal mutation rate: inherited genomes make some lineages mutate more readily than others.

Mutation is not an improvement operation. It has no view of the environment and no goal. Most changes are neutral or costly in a particular moment; occasionally a change helps its bearer leave more surviving descendants.

The energy economy

Every fixed simulation step performs an accounting exercise. An organism pays a basal cost for remaining alive and a movement cost influenced by speed and body size. Energy efficiency reduces both. Environmental harshness increases them.

In compact form:

const basal = (basalEnergyCost * harshnessMultiplier) / efficiency;
const movement = (
  movementEnergyCost *
  (speed / 50) *
  (bodySize / 7) *
  harshnessMultiplier
) / efficiency;

organism.energy -= (basal + movement) * deltaTime;

Touching an amber nutrient particle transfers its energy to the organism, modified slightly by inherited efficiency. The cell briefly swells, its feeding groove turns gold, nutrient sparks converge, and a GULP marker names the event. Reaching the effective reproduction threshold divides the available energy: 55 per cent remains with the parent and 45 per cent starts the offspring. Reproduction therefore has a real immediate cost, shown as a bright division ring around the small translucent child and its parent.

An organism dies when energy reaches zero or age reaches its inherited, environmentally scaled lifespan. Harshness adds a small background pressure. Predators, when enabled, pursue the nearest organism and can cause a fourth kind of death. The census records starvation, age, pressure, and predation separately even though the headline displays their sum.

Sensing without planning

The microbes have no map and perform no path search. At each step the engine asks two local questions:

  1. Is food inside this organism’s effective sensory radius?
  2. Is a predator inside a slightly larger danger radius?

The heading bends towards detected food according to food attraction and away from danger according to danger avoidance. A small random turn prevents perfectly straight, mechanical routes. When two microbes overlap, a spatial collision pass separates them, turns their headings apart, briefly squashes both bodies, and produces a cyan BUMP ripple. After movement, the elliptical boundary pushes an escaping organism back inside and changes its heading.

This produces behaviour that looks purposeful, but the distinction matters: apparent pursuit emerges from repeated steering arithmetic. Nothing in the code represents desire.

A clock that can be reproduced

Interactive animation often uses the time between screen frames directly. That is convenient but makes model outcomes depend on whether a device draws 42 or 60 frames per second. Here, drawing and biology have separate clocks.

The browser uses requestAnimationFrame for rendering. Elapsed real time accumulates, but the engine advances only in fixed steps of one-thirtieth of a simulated second:

accumulator += elapsed * simulationSpeed;

while (accumulator >= FIXED_TIME_STEP && steps < 8) {
  engine.step(FIXED_TIME_STEP);
  accumulator -= FIXED_TIME_STEP;
  steps += 1;
}

The eight-step guard prevents a backgrounded or struggling tab from attempting an enormous catch-up. When the garden leaves the viewport, its observer cancels the animation frame entirely. Returning starts a fresh display clock without inventing hours of invisible evolution.

All chance comes from a tiny seeded pseudo-random generator. Food positions, founder genomes, mutations, wandering, pressure, and predator creation consume the same reproducible random stream. With the same seed, parameters, and exact sequence and number of fixed simulation steps, the engine produces the same history. Real-time interactive runs may diverge if actions occur at different simulation times or a struggling browser drops catch-up time.

Advance 8 simulated seconds has a literal meaning. Biological generations overlap; parents and children coexist. The button pauses the garden and advances exactly 240 fixed ticks—eight simulated seconds at the base step. Zero, one, or many births may occur during that interval.

How one engine step works

Separation between the model and its picture is the central engineering choice. artificialLifeEngine.ts knows nothing about Canvas, Svelte, sliders, screen pixels, or CSS. It owns arrays of organisms, food, predators, and short-lived events.

Each fixed step follows this order:

  1. Accumulate the requested food spawn rate and add bounded nutrient particles.
  2. Synchronise predators with the enabled state and harshness.
  3. For every living organism, sense, steer, move, pay energy, eat, age, die, or reproduce.
  4. Resolve nearby organism collisions with a small spatial grid rather than comparing every possible pair.
  5. Update predators and resolve contact deaths.
  6. Age birth, feeding, collision, and cause-specific death events, removing expired ones.

Rendering reads the resulting state. Instead of identical moving dots, it draws soft-bodied procedural microbes with inherited silhouettes, flagella, cilia, armour, nuclei, vacuoles, speckles, and feeding grooves. Juveniles begin small and translucent, adults become vivid, and elders fade, lose some cilia, and acquire membrane scars as they approach their effective lifespan. Low energy deflates the body. Feeding, collision, division, starvation, old age, environmental pressure, and predation have distinct short-lived animations and labels. Coral predators now have mouths, eyes, tentacles, and a visible golden stomach pulse after swallowing prey. The renderer can exaggerate these cues for legibility without changing the inherited values or survival rules they report.

The statistics panel also reads the model. It calculates averages across the living population, finds the dominant hue family, estimates the deepest extant generation, and identifies the deepest surviving lineage: the founder lineage containing the highest-generation living descendant. Ties go to the lineage with more living descendants and then the lower lineage ID. The engine returns that lineage’s ID and label to both the statistics and Canvas renderer, so the text and white highlight rings cannot disagree. Lightweight SVG charts keep only the latest 120 samples rather than allowing an unbounded history.

Experiments worth trying

The presets change groups of scientifically related conditions, not visual themes.

Abundant Garden

Food appears frequently and carries generous energy. Background cost and harshness are low. Many genomes can survive, so diversity may persist and selection can be comparatively weak. Watch whether the population repeatedly approaches the cap. Then halve the food rate without restarting; the previously sustainable population has become an overshoot.

Scarcity and Competition

Many founders enter a world with sparse food, lower energy rewards, and higher costs. Run it several times with different seeds but identical settings. If different lineages dominate, you are seeing history and drift interact with selection. Selection constrains outcomes; it does not make every repeat identical.

High Mutation Chaos

Frequent, large mutations explore trait space quickly. The result need not be faster adaptation. Successful combinations are also easier to disrupt, and many extreme offspring pay severe energy costs. Compare the trait histograms with Abundant Garden. Variation should broaden, while persistence may become erratic.

Predator–Prey Arms Race

Coral predators make sensing, avoidance, and speed useful, but moving faster remains expensive. Highlight the deepest surviving lineage and watch which descendants persist. Then raise movement cost. A trait that helped under the old energy budget can become unaffordable under the new one.

A founder-effect experiment

Restore defaults, reduce Starting population to eight, set a seed, and restart. Record the dominant phenotype after several eight-second advances. Repeat with different seeds. Now use 150 founders. Larger starting populations sample more initial variation and usually make the early result less hostage to a few chance genomes.

Test the hard population cap

Load Abundant Garden and lower Population cap — browser safety limit while the simulation runs. Births stop at the cap, and if the new cap is below the current census, the weakest-energy organisms are removed as pressure deaths. This is a computational guard, not biological carrying capacity. The closer ecological analogue is the emergent sustainable population produced by food renewal, food energy, competition, metabolic cost, harshness, predation, and ageing.

Reading the charts without telling stories too quickly

A rising average speed does not prove that speed “evolved for” some purpose. The population could have lost slow lineages through predation, gained fast mutants, experienced a founder effect, or simply drifted during a bottleneck. The chart reports what happened, not why.

Good interpretation compares controlled runs:

  • keep parameters fixed and change only the seed to examine drift;
  • keep the seed fixed and change one environmental control to examine pressure;
  • restart after changing founder count or seed, because those define initial conditions;
  • inspect population, births, deaths, energy, and trait distributions together;
  • repeat before treating one dramatic run as a law.

“Dominant phenotype” groups hue into a small named colour family. It is a visible summary, not a species definition. “Generation estimate” is the maximum generation number among living organisms, not a claim that the whole population advances in synchronised cohorts.

Browser performance and accessibility

The simulation is designed to remain polite on an ordinary laptop or tablet.

  • Canvas resolution uses a capped device-pixel ratio rather than blindly matching very dense screens.
  • The population and food arrays are bounded; completed items use swap-removal instead of repeatedly shifting arrays.
  • Telemetry and charts update less often than the Canvas frame.
  • The component pauses its animation loop while offscreen and removes observers and listeners when destroyed.
  • Most controls affect the existing engine immediately. Starting population and Deterministic seed explicitly wait for a restart.
  • Native buttons, range inputs, text inputs, checkboxes, and a select remain usable by keyboard and touch.
  • Every control includes a direct scientific explanation, while numerical statistics communicate state without depending on colour.
  • Reduced-motion preferences start the garden paused and suppress movement trails. The reader must choose Resume.
  • If Canvas is disabled with ?canvas=off, the original poster replaces the live habitat while the article and explanations remain available.

Canvas itself exposes a concise screen-reader description. The adjacent census provides the meaningful changing state in text. An animation cannot narrate every organism accessibly, but population, lineage, energy, phenotype, resource, predator, and performance values do not disappear behind pixels.

Limitations of the garden

Honesty is part of the model.

The organisms reproduce asexually; there is no recombination. Genes act directly rather than through development. Food is homogeneous. Predators do not reproduce or evolve. Space is continuous but shallow: there are no territories, barriers, seasons, niches, or resource types. Sensing is perfect inside a radius. Mutation has simple independent probabilities. The hard population limit is partly a browser safety mechanism. The hue phenotype has no biological cost.

Most importantly, the rules were chosen by a human. Emergence means the exact population history is not scripted; it does not mean the system is free of design assumptions.

These limits suggest productive extensions rather than invalidate the exercise. A later Artificial Life Lab could add sexual reproduction, co-evolving predators, seasonal resource fields, toxin resistance, signalling, multicellular adhesion, spatial memory, or a changing genome-to-phenotype map. A second habitat could test migration and gene flow. A lineage tree could reveal bottlenecks that summary averages hide.

Read the executable model

The drawer contains the same modular TypeScript used by the live garden: the fixed-step engine, bounded genome operations, organism factories, environment geometry, seeded random generator, and four presets. The Svelte layer renders and controls these modules; it does not contain a second hidden simulation.

Complete Artificial Life Lab model source

artificialLifeEngine.ts

import { inheritGenome, GENOME_BOUNDS } from './genome';
import {
	angleDifference,
	createFoodParticle,
	createPredator,
	keepInsideDish,
	nearestFoodIndex,
	nearestOrganismIndex,
	nearestPredatorIndex,
	phenotypeName
} from './environment';
import { createFounder, createOffspring } from './organism';
import { SeededRandom } from './seededRandom';
import { DEFAULT_SIMULATION_PARAMETERS } from './simulationPresets';
import type {
	DeathCause,
	DeathCounts,
	FoodParticle,
	LineageSelection,
	Organism,
	Predator,
	SimulationEvent,
	SimulationParameters,
	SimulationStats,
	TraitBin,
	TraitDistributions,
	TraitKey
} from './types';

function clamp(value: number, minimum: number, maximum: number) {
	return Math.min(maximum, Math.max(minimum, value));
}

function average(total: number, count: number) {
	return count === 0 ? 0 : total / count;
}

function swapRemove<Value>(values: Value[], index: number) {
	const lastIndex = values.length - 1;
	if (index < 0 || index > lastIndex) return;
	if (index !== lastIndex) values[index] = values[lastIndex];
	values.pop();
}

export class ArtificialLifeEngine {
	parameters: SimulationParameters;
	seed: string;
	random: SeededRandom;
	organisms: Organism[] = [];
	food: FoodParticle[] = [];
	predators: Predator[] = [];
	events: SimulationEvent[] = [];
	simulationTime = 0;
	totalBirths = 0;
	deathCounts: DeathCounts = { starvation: 0, age: 0, predation: 0, pressure: 0 };
	lineageSelection: LineageSelection = { id: null, label: 'No extant lineage' };
	private foodAccumulator = 0;
	private nextOrganismId = 1;
	private nextFoodId = 1;
	private nextPredatorId = 1;

	constructor(
		parameters: SimulationParameters = DEFAULT_SIMULATION_PARAMETERS,
		seed = 'evolving-microbe-garden'
	) {
		this.parameters = { ...parameters };
		this.seed = seed;
		this.random = new SeededRandom(seed);
		this.restart(parameters, seed);
	}

	restart(parameters: SimulationParameters = this.parameters, seed = this.seed) {
		this.parameters = { ...parameters };
		this.seed = seed.trim() || 'evolving-microbe-garden';
		this.random = new SeededRandom(this.seed);
		this.organisms.length = 0;
		this.food.length = 0;
		this.predators.length = 0;
		this.events.length = 0;
		this.simulationTime = 0;
		this.totalBirths = 0;
		this.deathCounts = { starvation: 0, age: 0, predation: 0, pressure: 0 };
		this.foodAccumulator = 0;
		this.nextOrganismId = 1;
		this.nextFoodId = 1;
		this.nextPredatorId = 1;
		this.lineageSelection = { id: null, label: 'No extant lineage' };

		const founderCount = Math.min(
			Math.round(this.parameters.startingPopulation),
			Math.round(this.parameters.populationLimit)
		);
		for (let index = 0; index < founderCount; index += 1) {
			const organism = createFounder(this.nextOrganismId, this.random, this.parameters);
			this.nextOrganismId += 1;
			this.organisms.push(organism);
		}

		const startingFood = Math.min(Math.max(30, founderCount * 2), this.maximumFoodParticles());
		for (let index = 0; index < startingFood; index += 1) this.spawnFood();
		this.synchronizePredators();
		this.refreshLineageSelection();
	}

	setParameters(parameters: SimulationParameters) {
		this.parameters = { ...parameters };
		const limit = Math.max(1, Math.round(parameters.populationLimit));
		while (this.organisms.length > limit) {
			let weakestIndex = 0;
			for (let index = 1; index < this.organisms.length; index += 1) {
				if (this.organisms[index].energy < this.organisms[weakestIndex].energy)
					weakestIndex = index;
			}
			this.removeOrganism(weakestIndex, 'pressure');
		}
		this.synchronizePredators();
		this.refreshLineageSelection();
	}

	private maximumFoodParticles() {
		return Math.max(80, Math.round(this.parameters.populationLimit * 1.25));
	}

	private spawnFood() {
		if (this.food.length >= this.maximumFoodParticles()) return;
		this.food.push(
			createFoodParticle(this.nextFoodId, this.random, this.parameters.foodEnergyValue)
		);
		this.nextFoodId += 1;
	}

	private synchronizePredators() {
		if (!this.parameters.predatorsEnabled) {
			this.predators.length = 0;
			return;
		}

		const targetCount = clamp(Math.round(2 + this.parameters.environmentalHarshness * 3), 2, 5);
		while (this.predators.length < targetCount) {
			this.predators.push(createPredator(this.nextPredatorId, this.random));
			this.nextPredatorId += 1;
		}
		if (this.predators.length > targetCount) this.predators.length = targetCount;
	}

	private refreshLineageSelection() {
		const lineages = new Map<number, { deepestGeneration: number; livingCount: number }>();
		for (const organism of this.organisms) {
			const summary = lineages.get(organism.lineageId) ?? {
				deepestGeneration: 0,
				livingCount: 0
			};
			summary.deepestGeneration = Math.max(summary.deepestGeneration, organism.generation);
			summary.livingCount += 1;
			lineages.set(organism.lineageId, summary);
		}

		const selected = Array.from(lineages, ([id, summary]) => ({ id, ...summary })).sort(
			(a, b) =>
				b.deepestGeneration - a.deepestGeneration || b.livingCount - a.livingCount || a.id - b.id
		)[0];

		this.lineageSelection = selected
			? {
					id: selected.id,
					label: `L-${String(selected.id).padStart(3, '0')} · generation ${selected.deepestGeneration} · ${selected.livingCount} living`
				}
			: { id: null, label: 'No extant lineage' };
	}

	private effectiveTrait(
		inheritedValue: number,
		controlValue: number,
		defaultControlValue: number
	) {
		return inheritedValue * (controlValue / defaultControlValue);
	}

	private removeOrganism(index: number, cause: DeathCause) {
		const organism = this.organisms[index];
		if (!organism) return;
		this.events.push({
			kind: 'death',
			x: organism.x,
			y: organism.y,
			hue: organism.genome.hue,
			age: 0,
			duration: cause === 'predation' ? 1 : 0.9,
			cause
		});
		this.deathCounts[cause] += 1;
		swapRemove(this.organisms, index);
	}

	private updateOrganisms(deltaTime: number) {
		const initialCount = this.organisms.length;
		for (let index = initialCount - 1; index >= 0; index -= 1) {
			const organism = this.organisms[index];
			if (!organism) continue;
			const genome = organism.genome;
			organism.feedingPulse = Math.max(0, organism.feedingPulse - deltaTime * 1.8);
			organism.collisionPulse = Math.max(0, organism.collisionPulse - deltaTime * 2.4);
			organism.birthPulse = Math.max(0, organism.birthPulse - deltaTime * 1.35);
			const effectiveSensoryRadius = this.effectiveTrait(
				genome.sensoryRadius,
				this.parameters.sensoryRange,
				DEFAULT_SIMULATION_PARAMETERS.sensoryRange
			);
			const nearestFood = nearestFoodIndex(
				organism.x,
				organism.y,
				effectiveSensoryRadius,
				this.food
			);
			const nearestPredator = nearestPredatorIndex(
				organism.x,
				organism.y,
				effectiveSensoryRadius * 1.1,
				this.predators
			);

			organism.previousX = organism.x;
			organism.previousY = organism.y;
			organism.heading += this.random.signed() * genome.turningTendency * deltaTime * 1.8;

			if (nearestFood >= 0) {
				const particle = this.food[nearestFood];
				const desiredHeading = Math.atan2(particle.y - organism.y, particle.x - organism.x);
				organism.heading +=
					angleDifference(desiredHeading, organism.heading) *
					clamp(genome.foodAttraction * deltaTime * 1.7, 0, 0.8);
			}

			if (nearestPredator >= 0) {
				const predator = this.predators[nearestPredator];
				const escapeHeading = Math.atan2(organism.y - predator.y, organism.x - predator.x);
				organism.heading +=
					angleDifference(escapeHeading, organism.heading) *
					clamp(genome.dangerAvoidance * deltaTime * 2.6, 0, 0.9);
			}

			const movementSpeed = genome.movementSpeed;
			organism.x += Math.cos(organism.heading) * movementSpeed * deltaTime;
			organism.y += Math.sin(organism.heading) * movementSpeed * deltaTime;
			keepInsideDish(organism, this.random, genome.bodySize + 8);
			organism.age += deltaTime;

			const harshnessMultiplier = 1 + this.parameters.environmentalHarshness * 1.6;
			const efficiency = Math.max(0.35, genome.energyEfficiency);
			const basalCost = (this.parameters.basalEnergyCost * harshnessMultiplier) / efficiency;
			const movementCost =
				(this.parameters.movementEnergyCost *
					(movementSpeed / 50) *
					(genome.bodySize / 7) *
					harshnessMultiplier) /
				efficiency;
			organism.energy -= (basalCost + movementCost) * deltaTime;

			if (nearestFood >= 0 && this.food[nearestFood]) {
				const particle = this.food[nearestFood];
				if (Math.hypot(particle.x - organism.x, particle.y - organism.y) <= genome.bodySize + 4) {
					organism.energy += particle.energy * (0.8 + genome.energyEfficiency * 0.2);
					organism.feedingPulse = 1;
					this.events.push({
						kind: 'feeding',
						x: organism.x,
						y: organism.y,
						hue: organism.genome.hue,
						age: 0,
						duration: 0.7
					});
					swapRemove(this.food, nearestFood);
				}
			}

			const effectiveLifespan = this.effectiveTrait(
				genome.lifespan,
				this.parameters.maximumLifespan,
				DEFAULT_SIMULATION_PARAMETERS.maximumLifespan
			);
			if (organism.energy <= 0) {
				this.removeOrganism(index, 'starvation');
				continue;
			}
			if (organism.age >= effectiveLifespan) {
				this.removeOrganism(index, 'age');
				continue;
			}
			if (this.random.chance(this.parameters.environmentalHarshness * 0.004 * deltaTime)) {
				this.removeOrganism(index, 'pressure');
				continue;
			}

			const threshold = this.effectiveTrait(
				genome.reproductionThreshold,
				this.parameters.reproductionThreshold,
				DEFAULT_SIMULATION_PARAMETERS.reproductionThreshold
			);
			if (
				organism.energy >= threshold &&
				this.organisms.length < Math.round(this.parameters.populationLimit)
			) {
				const availableEnergy = organism.energy;
				organism.energy = availableEnergy * 0.55;
				organism.birthPulse = 1;
				const childGenome = inheritGenome(genome, this.random, this.parameters);
				const child = createOffspring(
					this.nextOrganismId,
					organism,
					childGenome,
					availableEnergy * 0.45,
					this.random
				);
				this.nextOrganismId += 1;
				keepInsideDish(child, this.random, child.genome.bodySize + 8);
				this.organisms.push(child);
				this.totalBirths += 1;
				this.events.push({
					kind: 'birth',
					x: child.x,
					y: child.y,
					hue: child.genome.hue,
					age: 0,
					duration: 0.65
				});
			}
		}
	}

	private resolveOrganismCollisions() {
		const cellSize = 30;
		const cells = new Map<number, number[]>();
		let emittedEvents = 0;

		for (let index = 0; index < this.organisms.length; index += 1) {
			const organism = this.organisms[index];
			const cellX = Math.floor(organism.x / cellSize);
			const cellY = Math.floor(organism.y / cellSize);

			for (let offsetY = -1; offsetY <= 1; offsetY += 1) {
				for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
					const neighbours = cells.get(cellX + offsetX + (cellY + offsetY) * 64);
					if (!neighbours) continue;
					for (const otherIndex of neighbours) {
						const other = this.organisms[otherIndex];
						let deltaX = organism.x - other.x;
						let deltaY = organism.y - other.y;
						let distance = Math.hypot(deltaX, deltaY);
						const minimumDistance = (organism.genome.bodySize + other.genome.bodySize) * 0.82;
						if (distance >= minimumDistance) continue;

						if (distance < 0.001) {
							const angle = (((organism.id * 97 + other.id * 53) % 360) * Math.PI) / 180;
							deltaX = Math.cos(angle);
							deltaY = Math.sin(angle);
							distance = 1;
						}

						const normalX = deltaX / distance;
						const normalY = deltaY / distance;
						const correction = (minimumDistance - distance) * 0.52;
						organism.x += normalX * correction;
						organism.y += normalY * correction;
						other.x -= normalX * correction;
						other.y -= normalY * correction;

						const collisionAngle = Math.atan2(normalY, normalX);
						organism.heading += angleDifference(collisionAngle, organism.heading) * 0.42;
						other.heading += angleDifference(collisionAngle + Math.PI, other.heading) * 0.42;

						const freshCollision = organism.collisionPulse <= 0.05 && other.collisionPulse <= 0.05;
						organism.collisionPulse = 1;
						other.collisionPulse = 1;
						if (freshCollision && emittedEvents < 6) {
							this.events.push({
								kind: 'collision',
								x: (organism.x + other.x) / 2,
								y: (organism.y + other.y) / 2,
								hue: (organism.genome.hue + other.genome.hue) / 2,
								age: 0,
								duration: 0.45
							});
							emittedEvents += 1;
						}
					}
				}
			}

			const key = cellX + cellY * 64;
			const members = cells.get(key);
			if (members) members.push(index);
			else cells.set(key, [index]);
		}

		for (const organism of this.organisms) {
			keepInsideDish(organism, this.random, organism.genome.bodySize + 8);
		}
	}

	private updatePredators(deltaTime: number) {
		for (const predator of this.predators) {
			predator.previousX = predator.x;
			predator.previousY = predator.y;
			predator.cooldown = Math.max(0, predator.cooldown - deltaTime);
			predator.feedingPulse = Math.max(0, predator.feedingPulse - deltaTime * 1.5);
			const targetIndex = nearestOrganismIndex(predator.x, predator.y, this.organisms);
			if (targetIndex >= 0) {
				const target = this.organisms[targetIndex];
				const desiredHeading = Math.atan2(target.y - predator.y, target.x - predator.x);
				predator.heading +=
					angleDifference(desiredHeading, predator.heading) * clamp(deltaTime * 2.4, 0, 0.7);
			} else {
				predator.heading += this.random.signed() * deltaTime;
			}

			const predatorSpeed = 52 + this.parameters.environmentalHarshness * 18;
			predator.x += Math.cos(predator.heading) * predatorSpeed * deltaTime;
			predator.y += Math.sin(predator.heading) * predatorSpeed * deltaTime;
			keepInsideDish(predator, this.random, predator.radius + 12);

			if (targetIndex < 0 || predator.cooldown > 0 || !this.organisms[targetIndex]) continue;
			const target = this.organisms[targetIndex];
			if (
				Math.hypot(target.x - predator.x, target.y - predator.y) <=
				predator.radius + target.genome.bodySize
			) {
				this.removeOrganism(targetIndex, 'predation');
				predator.cooldown = 0.55;
				predator.feedingPulse = 1;
			}
		}
	}

	private updateEvents(deltaTime: number) {
		for (let index = this.events.length - 1; index >= 0; index -= 1) {
			this.events[index].age += deltaTime;
			if (this.events[index].age >= this.events[index].duration) swapRemove(this.events, index);
		}
		if (this.events.length > 140) this.events.splice(0, this.events.length - 140);
	}

	step(deltaTime: number) {
		const step = clamp(deltaTime, 0.001, 0.1);
		this.simulationTime += step;
		this.foodAccumulator += this.parameters.foodSpawnRate * step;
		while (this.foodAccumulator >= 1) {
			this.spawnFood();
			this.foodAccumulator -= 1;
		}
		this.synchronizePredators();
		this.updateOrganisms(step);
		this.resolveOrganismCollisions();
		this.updatePredators(step);
		this.updateEvents(step);
		this.refreshLineageSelection();
	}

	stepMany(steps: number, deltaTime: number) {
		for (let index = 0; index < steps; index += 1) this.step(deltaTime);
	}

	private totalDeaths() {
		return Object.values(this.deathCounts).reduce((total, count) => total + count, 0);
	}

	statistics(framesPerSecond = 0): SimulationStats {
		let totalEnergy = 0;
		let totalSpeed = 0;
		let totalSize = 0;
		let totalSensoryRadius = 0;
		let totalMutationRate = 0;
		let generationEstimate = 0;
		const phenotypes = new Map<string, number>();

		for (const organism of this.organisms) {
			totalEnergy += organism.energy;
			totalSpeed += organism.genome.movementSpeed;
			totalSize += organism.genome.bodySize;
			totalSensoryRadius += organism.genome.sensoryRadius;
			totalMutationRate += organism.genome.mutationRate;
			generationEstimate = Math.max(generationEstimate, organism.generation);
			const phenotype = phenotypeName(organism.genome.hue);
			phenotypes.set(phenotype, (phenotypes.get(phenotype) ?? 0) + 1);
		}

		const dominantPhenotype =
			Array.from(phenotypes.entries()).sort(
				(a, b) => b[1] - a[1] || a[0].localeCompare(b[0])
			)[0]?.[0] ?? 'No dominant phenotype';
		return {
			simulationTime: this.simulationTime,
			population: this.organisms.length,
			births: this.totalBirths,
			deaths: this.totalDeaths(),
			deathCounts: { ...this.deathCounts },
			generationEstimate,
			averageEnergy: average(totalEnergy, this.organisms.length),
			averageSpeed: average(totalSpeed, this.organisms.length),
			averageSize: average(totalSize, this.organisms.length),
			averageSensoryRadius: average(totalSensoryRadius, this.organisms.length),
			averageMutationRate: average(totalMutationRate, this.organisms.length),
			deepestSurvivingLineageId: this.lineageSelection.id,
			deepestSurvivingLineage: this.lineageSelection.label,
			dominantPhenotype,
			foodAvailability: this.food.length,
			predatorCount: this.predators.length,
			framesPerSecond
		};
	}

	private distribution(key: TraitKey, binCount = 8): TraitBin[] {
		const [minimum, maximum] = GENOME_BOUNDS[key];
		const width = (maximum - minimum) / binCount;
		const bins = Array.from({ length: binCount }, (_, index) => ({
			from: minimum + width * index,
			to: minimum + width * (index + 1),
			count: 0
		}));
		for (const organism of this.organisms) {
			const value = organism.genome[key];
			const index = clamp(Math.floor((value - minimum) / width), 0, binCount - 1);
			bins[index].count += 1;
		}
		return bins;
	}

	traitDistributions(): TraitDistributions {
		return {
			movementSpeed: this.distribution('movementSpeed'),
			bodySize: this.distribution('bodySize'),
			sensoryRadius: this.distribution('sensoryRadius'),
			mutationRate: this.distribution('mutationRate')
		};
	}
}

One useful idea to keep

Evolution does not require a creature to understand evolution. In this garden, each organism follows local rules: spend, sense, move, eat, copy, vary, and eventually disappear. Yet the population gains a history. Lineages expand and collapse. Trait distributions move. Chance matters most when numbers are small. A solution under abundance becomes a liability under scarcity.

That is the lesson the simulation can honestly offer. Not that a few circles have become alive, but that inheritance plus variation plus differential survival can turn simple bookkeeping into an unpredictable, inspectable population story.

Word Cloud

Word cloud for Artificial Life Lab: Evolve a Digital Ecosystem in Your Browser