A grid-world evolution sim where natural selection is an emergent property of a closed energy economy, plus a dashboard that lets you watch it and interrogate it.
There is no fitness function anywhere in this code. Nothing scores an organism. An organism either has enough energy left over to split, or it does not. Everything that looks like "selection" is bookkeeping about who can pay their bills.
python -m evo.run --preset arms_race --live --out out/live
open out/live.html
That writes a self-contained HTML file. No server, no build step, no internet — the whole simulator is inlined in the page and runs in your browser.
--live is the agar.io-style view: the world steps in real time, organisms are drawn every
frame, and the graphs below redraw about five times a second while you watch. It is a live
run, not a recording — the scrubber follows the frontier, and dragging it back pauses and
rewinds; press resume to rejoin. Speed is --steps: how many ticks one animation frame
computes. Raise it to run faster than real time, at the cost of a choppier world. The
lineage tree keeps growing as you watch, so it is rebuilt on that same five-times-a-second
beat rather than every frame.
Drop --live and the sim runs in Python instead, with a finished trace baked into the page.
Slower to produce, but it carries every tick, so the scrubber has full resolution:
One tick, in order:
food regrows → every organism senses, moves, attacks, eats, burns, ages → maybe splits
→ anything at zero energy, or past its lifespan, dies
Seven traits, each in [0, 1]:
| trait | buys | costs |
|---|---|---|
speed |
more cells travelled per tick, and better at catching prey | move_cost per cell travelled |
sense |
a wider, smoother view of the food field | attention is expensive per tick |
size |
energy capacity, and the right to eat smaller organisms | quadratic upkeep, and digests plants worse |
metabolism |
assimilates prey better, digests food better | the most expensive trait in the world |
repro |
(nothing directly) | raises the energy threshold needed to split |
lifespan |
lives longer | upkeep for every tick lived |
hue |
nothing | nothing — a neutral marker so a lineage is visible as a colour |
Energy enters as food_regrow per cell per tick and never any other way. Carrying
capacity is therefore an outcome of the model, never a parameter.
Everything below was learned the hard way, by watching graphs go flat.
This is the whole model. A free trait evolves to its ceiling, every organism reaches that
ceiling, the population becomes one clone, and every graph is a straight line. The cost
table in World.derive is where natural selection actually lives — that one function is
the entire "environment" the organisms are adapting to.
The first version let capacity scale with size. Size ratcheted to 1.0 and stopped. Worse, once everyone is big, any small mutant gets eaten before it establishes, so the population locks into one point of trait space forever — a permanent evolutionary trap, and a permanently flat dashboard.
Now capacity is 38 + 14·size (barely anything) and plant digestion is multiplied by
1 − 0.6·size. Being large is a net loss unless you can catch something. Size only
pays for itself through killing, which is what converts "bigger is better" into a
two-sided arms race.
Predation was dead code for two separate reasons, both invisible:
- the search used
maximum_filter, which returns the max count in a 3×3 block, not the sum, so an attack still required two organisms in the same cell — roughly 1 in 3000; - both sides sensed the same thing, so the size classes never intersected.
Now organisms pull toward the size class below them and push away from the class above them. Kills went from essentially never, to several per tick sustained for thousands of ticks.
arms_race, 4000 ticks, seed 1 (mean of the last 2400 ticks). Regenerate with
python -m evo.run --preset arms_race --ticks 4000 --seed 1 --report --no-html:
pop 660-761, no trend diversity 1.81 (ceiling ln16 = 2.77)
predation 4.4 kills/tick sustained, peaks of 26
19,114 organisms born from 100 founders; deepest surviving lineage 42 generations
trait means: speed 0.43 sense 0.23 size 0.51 metabolism 0.96 repro 0.09 lifespan 0.33
The means are the least interesting part. Histogrammed at the end of the run, the population has broken into discrete strategies, not a smooth cloud:
bin 0.00-.125 .125-.25 .25-.375 .375-.5 .50-.625 .625-.75 .75-.875 .875-1.0
speed 340 57 22 9 11 13 32 239
size 133 133 65 84 18 9 43 238
Speed is bimodal: a large sit-and-wait population at ~0.05 and a fast one at ~0.9, with almost nothing in between. Running is expensive, and the middle — fast enough to cost a lot, not fast enough to reliably catch things — is the worst place to be. Size is split the same way, which is the signature that predation is a real force rather than a bonus: small-and-cheap and large-and-dangerous both persist, and the intermediate size is the one that starves.
metabolism climbing to 0.96 while repro falls to 0.09 is the world paying for
digestion quality by giving up reproductive readiness.
Global population is stable, not oscillating. I went looking for boom-bust cycles with the sweep and could not find them at this grid size — the food field regenerates faster than the generation time, which damps the classic Lotka-Volterra swing. What does happen is spatial: the food field has strong local structure (sd 0.25 on a mean of 0.46), so grazing waves move across the map even while the global total sits still. Watch the food background in the Replay tab for that.
The dashboard has four tabs.
Replay — the world, coloured by any trait, over the food field. Scrub or play. Colour
by hue and you see lineages as blobs of colour that split and vanish; colour by size
and you see the herbivore/predator split arrange itself spatially.
Charts — the diagnostic set:
| graph | what it proves |
|---|---|
| Trait means | selection's footprint. If any line sits at 1.0, that trait is free — a bug |
| Trait std | polymorphism. A collapse to 0 means one genotype swept the world |
| Size ridgeline | the population splitting into two feeding strategies, live |
| Speed ridgeline | whether running is under selection or just drifting |
| Speed × size scatter | the trade-off frontier. The empty middle is where selection punishes generalists |
| Sense × metabolism | the "cheap and blind vs far-seeing and hungry" split |
| Deaths by cause | what the selective pressure actually is, which is the point of the whole exercise |
| Energy partition | energy in bodies vs in the environment — the closed system, made visible |
| Flux | births vs deaths; their crossing precedes population turns |
Lineage — the tree of life. Every organism that ever lived, parent → offspring, height = time, colour = the neutral hue marker. Faint branches are extinct. Sample size is a slider because a full 18,000-node tree is a comb, not a tree. It reports the most recent common ancestor of the survivors, which for these runs does not exist — multiple founder lines are still segregating at tick 4000, which is what you'd expect from 100 founders on a 64×64 torus with real spatial structure.
Rules — the model written out, with the live cost table and current trait means.
python -m evo.run --list-presets
python -m evo.run --preset arms_race --live --out out/live # watch it run in the browser
python -m evo.run --preset arms_race --live --steps 12 --out out/fast # faster than real time
python -m evo.run --preset famine --set food_regrow=0.03 --seed 7 --out out/fam
python -m evo.run --preset arms_race --ticks 4000 --out out/arms --save-state
python -m evo.run --resume out/arms.state.json --ticks 2000 --out out/arms2
# find the interesting corners of parameter space instead of guessing
python -m evo.sweep --preset arms_race --ticks 1500 --jobs 4 \
--sweep food_regrow=0.02,0.05,0.10 --sweep carnivory=0.2,0.6,0.9
python -m evo.sweep ... --top 3 --save # dashboards for the best 3
python -m tests.test_sim # invariants
python -m evo.golden && node tests/test_parity.js # sim.py <-> web/sim.js parity
python -m evo.controls # regenerate the controls table below
node tools/render_check.js out/arms.html # dashboard actually renders
node tools/render_check.js out/live.html # live page boots the Python world and advancesPresets: ancestral eden famine arms_race mutagenic peaceful flat.
peaceful (no predation) and flat (no spatial patches) exist as controls — they are
what you run to check that the interesting behaviour has a cause. 2500 ticks, mean of the
last 1500:
| world | diversity | sd(size) | kills/tick | pop |
|---|---|---|---|---|
arms_race (everything on) |
2.04 | 0.213 | 3.72 | 706 |
peaceful — no predation |
1.17 | 0.078 | 0 | 907 |
flat — no fertility patches |
2.06 | 0.150 | 1.37 | 570 |
carnivory=0 — size costs nothing to digest |
1.18 | 0.016 | 0.15 | 851 |
Regenerate with python -m evo.controls.
Read the last row first, because it is the surprising one. Turning off only the rule that large bodies digest plants badly doesn't weaken predation, it switches predation off (3.7 → 0.15 kills/tick) and collapses size polymorphism 13-fold. Being big has to be a net loss for being big to ever be contested. The arms race is created by the cost of size, not by the existence of teeth.
Removing predation halves diversity (2.04 → 1.17) and removes two thirds of the size variation, so predation is maintaining roughly a third of the world's niches. Removing spatial patchiness costs nothing at all in diversity (2.06 vs 2.04) and only a third of the size variation — the size trade-off, not the landscape, is doing all of the work. That revised an earlier design assumption of mine: I had treated patchy fertility as one of the three load-bearing rules, and the control says it is a supporting actor.
The sweep agrees, but less cleanly than I first wrote. 3x3x2 grid, 18 cells (food_regrow x
carnivory x predation), 1500 ticks:
python -m evo.sweep --preset arms_race --ticks 1500 --jobs 4 \
--sweep food_regrow=0.02,0.05,0.10 --sweep carnivory=0.2,0.6,0.9 --sweep predation=true,falseThe top six of six cells are predation=true, and the single best (0.74) beats the best
predation-off cell (0.47) by a wide margin. But the ranges overlap: predation-on spans
0.33-0.74 and predation-off 0.34-0.47, and paired cell-by-cell predation wins 7 of 9. The
two it loses are both at food_regrow=0.02 - when there is no primary-production surplus,
a predator lineage has nothing to convert. Predation predicts an interesting world given
resources, not universally. That is a better result than the clean split I claimed
earlier, and it only appeared once the grid included the famine end.
One caveat worth keeping: this is a single seed per cell. The predation-vs-famine interaction is exactly the kind of result that wants three seeds before anyone should trust the direction of those two losses.
tests/test_sim.py guards the ways this kind of model fails silently:
- energy is never created — the world can lose energy to clipping and waste heat, but
Δ(food + bodies) ≤ energy injected. This caught a real bug: two organisms in one cell each ate the whole meal because the code read a stale copy of the food list, and food went negative. - determinism — same seed, same world.
- save/resume is exact — a resumed run is byte-identical to one that never stopped.
- snapshots don't perturb the sim — playback frames are taken every 50 ticks, so a shared RNG here would silently change the science.
- traits don't all saturate, selection actually moves something, size stays polymorphic, predation is not dead code, extinction doesn't crash, the lineage graph is a forest (every parent is older than its child).
evo/web/sim.js is a second implementation of the same model, so the browser can run the
world live instead of replaying a baked trace. Two implementations can disagree silently,
so tests/test_parity.js runs both from the same Mersenne Twister state and asserts:
- exact agreement for the first 15 ticks — population identical to the organism, every trait mean within 1e-9. This is the sharp instrument: a missing cost term or a wrong filter radius shows up immediately, not as slow drift.
- bounded statistical agreement afterwards. JS
Math.logis not correctly rounded, sogaussdiffers from CPython by ~1 ulp and a chaotic sim amplifies that. Measured drift over 600 ticks across 6 runs: population within 9%, trait means within 0.12.
Regenerate the reference with python -m evo.golden. The arms_race_dense case exists
because at natural density the crowding gate and the prey fields are both zero for the
first dozen ticks, so predation and hunt/flee steering can be deleted outright and the
trajectory comparison still passes — the dense fixture forces both paths to fire inside the
exact window. Deleting one cost term at a time, the test catches 10 of 10 injected bugs.
This test found a bug in the reference model, not just the port: scipy.ndimage.uniform_filter
dispatches on dtype, and its integer path applies mode="wrap" incorrectly, disagreeing with
its own float path at the border. Both density fields fed it int64, so the predation gate was
partly driven by that quirk. evo/sim.py now uses an exact integer box sum instead —
dtype-independent, and the one formulation JS can match bit for bit.
evo/sim.py the model. Params, World, the tick loop, the trade-off table
evo/trace.py run a sim to a compact trace (stats + playback frames + lineage)
evo/run.py CLI: run, save trace, emit standalone dashboard
evo/web/ JS port of the model (prng.js, sim.js) used by --live
evo/sweep.py parameter grid, scored by how much selection it shows
evo/controls.py regenerate the controls table in this README
evo/golden.py emit the reference trace the JS port is checked against
evo/dashboard.html the whole front end; __TRACE__ is replaced with the run's JSON
evo/web/prng.js MT19937 + gauss, bit-exact with CPython's random
evo/web/sim.js the model, ported to typed-array SoA for the live browser world
evo/web/fixture.js dense test world that forces predation to fire
tests/test_sim.py invariants
tests/test_parity.js sim.py <-> web/sim.js agreement
tools/render_check.js headless check that the dashboard's JS runs and paints
metabolismis not a real trade-off yet. It runs to 0.95 because its benefit is multiplicative on everything the organism eats while its cost is a flat 0.05/tick, so it is always worth buying at this food level. A convex cost (or a cost that scales with intake) would make it a genuine fork. Every other trait is properly constrained.hueis genuinely neutral, so it drifts; it is a lineage marker, not an adaptation.- The 3×3 attack range and 9-cell sense neighbourhood are Chebyshev, so "distance" is square-ish. Fine here, wrong if you ever care about geometry.
max_popis a runtime guard, not ecology: a run that hits it is an artefact, and the sweep score penalises it rather than pretending it is a bloom.- No dispersal kernel, no age structure, no sex. Each of those is a real model change, not a parameter.