← All posts

Why I built a deterministic ecosystem simulator in Rust

World 2.0 is a zero-dependency Rust simulation where plants, herbivores and predators live on a torus, and the animals run on a 59-input MLP trained by imitation and a genetic algorithm. Here is why it exists and what I learned.

6 min read

This post is also available in Russian: Зачем я написал детерминированный симулятор экосистемы на Rust

I wanted to watch something alive that I could also debug. Not a game, not a screensaver: a world with an energy budget, animals that get hungry and thirsty, predators that hunt, and a population that either finds an equilibrium or collapses. And when it collapses, I wanted to know exactly why, replay the same run, and fix the cause.

That constraint decided most of the architecture. The result is World 2.0: a simulation core in Rust with no external crates, a bit-exact deterministic step function, and a browser viewer that renders the world as a rotating sphere. You can look at it in the live demo.

Determinism first

Every run is a pure function of a u64 seed. One PRNG (SplitMix64), one call order, no threads inside the step, no wall-clock time, no HashMap iteration where the order matters. Run --seed 7 today and in a year and you get the same population curve down to the last tick.

This sounds like a nice-to-have. It's the whole point. Ecosystem tuning is a search over a narrow stability window; "the herbivores died at tick 8412" is only useful if I can get back to tick 8411 with the same state. Determinism turns every crash into a reproducible test case. It also makes the optimizer honest: two configs evaluated on the same seeds are compared on the same world, not on luck.

The price is discipline. Floating point is deterministic on one machine, but I still avoid anything that depends on evaluation order across entities: the spatial hash grid returns neighbours in a stable order, and every "pick the closest" tie is broken by entity id. It's not hard, it's just relentless.

Zero dependencies

Cargo.toml has no [dependencies] section. The PRNG, the spatial grid, the YAML-ish config reader, the WebSocket server that streams frames to the viewer, the binary format for trained brains, the tiny neural network with backprop — all in the tree.

I didn't do this out of purism. I did it because a 30-second cargo build --release from a clean checkout, with lto = "thin" and codegen-units = 1, is what let me iterate on the hot loop hundreds of times. And because every crate I would have pulled in for "just the network" or "just the RNG" brings its own opinions about float order, and I wanted to own all of them.

The step function itself is boring on purpose: iterate species, sense, decide, move, resolve collisions with a separation force, eat, drink, breed, die, then let the plants regrow from the unused energy inflow. Energy is the accounting layer that keeps the world honest: the ground emits a fixed inflow, plants convert it, herbivores eat plants, predators eat herbivores, every transfer has an efficiency below one and every animal pays a basal metabolic cost per tick. Populations can't grow for free.

The torus that became a sphere

Topologically the world is a 2D torus: walk off the right edge, come back on the left. That's the cheapest way to get a world with no borders and no corner-case behaviour near walls. The viewer, though, wraps the torus onto a sphere in three.js. It's a lie — the geometry doesn't match — but it's a convincing one, and looking at a rotating planet with herds and packs moving across it is much more pleasant than a scrolling rectangle. The viewer connects to the core over a raw WebSocket and gets binary frames; the core doesn't know or care that it's being drawn.

Two kinds of brains

Each animal's behaviour comes from one of two controllers.

The first is a hand-written one I call the dummy (in Russian, bolvanchik). It's a utility-based decision tree: flee beats thirst, thirst beats mating, mating beats food, food beats wandering. Navigation is Bug2 — go straight at the target, and when you hit an obstacle, follow its wall until you can go straight again. It sees only through its sensors: rays for obstacles and threats, a memory of the last seen food and water, a few "breadcrumbs" of where it has been. No god view, no path-finding over the map. The dummy is boring, but it passes all of my test scenarios (376 of them at the time of writing) and keeps eight species alive together for thousands of ticks.

The second is a neural network. It is deliberately tiny: 59 inputs (sensor rays, distances and directions to food, water, threat, herd, mate, plus a one-hot of the species and a few memory cues), four recurrent registers appended to the input, 96 hidden tanh units, and an output head that is not a raw movement vector. Instead the network picks one of 17 pointers — "toward food", "away from threat", "toward herd", "current heading ± 45°", and so on — as a softmax classification, plus scalars for speed, eat, drink, mate and sleep. The pointer trick matters: when the right move is "go around the rock on the left OR the right", regression averages the two into "walk into the rock", while argmax commits to one side.

Training: imitate, then evolve

Gradient descent from scratch didn't work on this problem, and a genetic algorithm from scratch was hopelessly slow. What worked was the combination.

Stage 1, imitation. Run the dummy across every test scenario and many randomized variations of each, and record (sensors → action) pairs on every tick. Train the MLP with cross-entropy on the pointer head and MSE on the scalars. Then, DAgger-style, let the network drive for a while, hand the wheel back to the dummy from that state, and record the recovery — so the teacher labels the situations the student actually gets into, not just its own perfect trajectories. This gives a starting brain that already behaves like a competent animal.

Stage 2, evolution. Take a population of those brains and run a genetic algorithm. Fitness is the share of scenarios solved, weighted by difficulty, with a bonus for speed. The best mutate and cross over; the rest are discarded. Because the GA needs no gradient, the recurrent registers evolve without backpropagation through time — that's what makes memory cheap here. Fitness is measured on held-out variations of the scenarios, split by layout hash, so I can see "train 97% / val 98%" and know the brain hasn't just memorized the geometry. Evaluation runs on all cores with thread::scope; a generation went from nine minutes to about one.

The current predator brain solves 97% of the base scenarios and 98% of the validation set. The herbivores are behind, and the honest state of things is that eight species coexisting on the neural controller is still an open problem: they coexist on the dummy, and the network is close, but "close" in an ecosystem means a slow extinction.

What I'd tell myself at the start

  • Make the world deterministic before you make it interesting. Everything else — the optimizer, the training pipeline, the test scenarios — rests on being able to replay.
  • Build the dumb controller first and make it good. It's your test oracle, your training data, and your baseline in every comparison.
  • Classify directions, don't regress them.
  • Test on variations, not on the base layout. The first GA I ran learned the map.
  • The viewer is not a luxury. Half of the bugs I found, I found by watching a single animal do something stupid for thirty seconds.

The source, the training logs and the scenario suite are in the project; the presentation has the pictures, and the demo has the planet. I'll keep writing here as the herbivores learn.