The backend that runs Wilden started life as a Node/TypeScript server: it owns the world, ticks the simulation, and broadcasts state to every connected client. It worked, but a simulation of thousands of organisms all obeying the same rules is exactly the kind of workload that wants real concurrency and predictable performance. So I decided to migrate the entire backend to Rust without ever changing how the simulation behaves, and without touching the browser client.
It took hundreds of pull requests. Here's the course I followed.
1. Refine and simplify first
You don't port a mess; you port a contract. Before any Rust was written, I spent time simplifying the existing code: collapsing redundant entity-management paths, clarifying module boundaries, deleting dead branches. A migration is the worst possible time to discover that a module's behaviour was never really pinned down. The cleaner the TypeScript, the smaller the surface I'd have to reproduce exactly in Rust.
2. Get close to 100% meaningful test coverage
This was the precondition for everything else. I built out the test suite until nearly every observable behaviour was covered. Not line count, but contract: normal paths, boundary conditions, and the corner cases callers actually depend on. The client got its own suite of 300+ tests; the backend's behaviour was locked down module by module.
The reason coverage has to come first is subtle. If you write the tests after the Rust exists, they only prove the Rust does what you thought you wrote. Tests written against the original TypeScript, before any Rust is in sight, describe the real contract, untainted by the new implementation. They become the reference the Rust has to satisfy.
3. Migrate one unit at a time, behind a bridge
I used napi-rs as a foreign-function bridge so Rust could be called directly from Node. Then I ported the backend one module at a time, in dependency order, leaves first.
The utilities went early: hashing, quantization, comparison, the RNG, compression, time, IDs, binary serialization, Perlin noise. Then the domain core: energy and physics, environment elements, the organism (reproduction, movement, interaction, awareness), the world, statistics. Then the server layer: the client tracker, lineage, config, broadcast, state, routes.
Each module followed the same recipe. Port it to Rust, expose it through napi, and, critically, turn the original TypeScript file into a pure re-export of the Rust binding. No logic left in TypeScript; the .ts file becomes a one-line forward to the Rust implementation. Callers don't know or care that the work now happens in Rust.
One rule made this safe to do incrementally: the old implementation never came out until the new one was independently proven, with old and new running side by side behind the bridge for the entire migration. None of this touched production. The whole effort lived on a long-lived feature branch, and the live game kept running the Node backend until the very end.
4. Node tests cover both implementations
This is the trick that made each step verifiable. Because the TypeScript module became a thin re-export of the Rust binding, the same TypeScript tests now ran against the Rust code, through the napi seam. I didn't rewrite them. I re-pointed them.
The moment a Rust port diverged from the original TypeScript behaviour (a rounding difference, an off-by-one in an iteration order, a different RNG sequence) the existing test went red. The contract I'd captured in step 2 was now actively policing the port, automatically, on every commit. No "does this look right?" judgement calls.
5. Duplicate the tests in Rust, then add more
The napi-seam tests prove parity, but they test Rust as if it were TypeScript, through a foreign-function boundary. So alongside them I wrote Rust-native tests directly against the kernel (idiomatic assertions, real Rust types, no FFI overhead), duplicating what the jest suite covered and then going further into the corners that are awkward to reach from JavaScript. cargo clippy -D warnings became a non-negotiable fifth check next to type-check, the two test suites, and the build.
The two test layers run together, not instead of each other. A dedicated parity sweep made sure every assertion the jest suite covered had a Rust-native equivalent before the jest coverage could ever disappear. The TypeScript tests stay until the whole migration is done; pulling one early would remove a cross-validation signal that catches regressions introduced by later work.
6. Faithful first, idiomatic second
The first Rust draft of any module was a deliberately literal translation of the TypeScript: same control flow, same field names, same order of operations. Faithful, even where it was ugly. That's what kept the parity tests green and made each diff reviewable.
Then, with the behaviour pinned, I optimized, in a fixed priority order: raw performance, then idiomatic Rust, then developer ergonomics. Sometimes being idiomatic meant breaking from the Node architecture entirely: a pattern that made sense under a single-threaded garbage-collected runtime is often the wrong shape in Rust. Where the faithful translation fought the language, I changed the structure to fit Rust, and recorded why in an architecture decision record, so the deviation was a conscious, documented choice rather than drift.
7. Hold byte parity, and record every deviation
The governing invariant was byte parity: the same seed must always produce the same world, byte for byte, before and after a change. A determinism digest over the whole world state was the canary throughout. If a full simulation run was identical before and after a refactor, behaviour was preserved no matter how deep the change went.
Byte parity isn't free, though, and a few changes were worth deviating for. Each one was a sanctioned break: written up in an ADR with its rationale, gated by a statistical-faithfulness harness (proving the simulation's character, such as population curves, births, deaths by cause, and species counts, was unchanged even when the exact bytes moved), and re-baselined in the same PR. The rule was simple: you may leave byte parity, but only deliberately, and only on the record.
8. Remove the bridges, one by one
With everything ported and double-covered, the next phase was tearing out the bridge. This was the hard, behaviour-changing part, so it moved slowly and deliberately: one module's seam at a time, each with its corresponding jest tests retired only once the Rust-native coverage stood on its own.
I split each module into a pure-Rust kernel and a thin napi mirror, then collapsed the side effects (logging, stats, state mutation) down into the kernel so the TypeScript shims had nothing left to do. The biggest piece was dismantling a dependency-injection "bag" that had been threaded through every cross-organism interaction: each interaction was rewritten to operate directly on kernel-native state, removing the foreign-function calls from the simulation's hot loop entirely.
Finally, the cutover: a pure-Rust engine running on an axum/tokio HTTP and WebSocket server, the production entry point flipped to a standalone evolution-server binary, and then the napi seam and the entire TypeScript/jest layer deleted. The mapping layer was gone.
9. Update the docs, then review the whole thing again
A migration this long leaves a trail of prose that quietly goes stale: architecture docs, developer guides, config references, code comments that still describe a Node server. So a documentation pass was part of the definition of done, with every artifact rewritten to describe the system as it is now, in the present tense, and the why living in the ADRs.
Then a global review of the entire migration, not module by module this time, but the whole Rust codebase as one artifact. With the bridges gone and the architecture free of its Node-shaped constraints, I optimized again, in the same priority order as step 6: performance, idiomatic Rust, developer ergonomics. Things that had to stay literal while the seam existed could finally take their natural Rust shape.
Review and fix until it converges
"Tests pass" was never the bar for merging. Every change set went through repeated review-and-fix passes, judging migration fidelity, idiomatic style, performance, and corner-case coverage, and I kept looping until a fresh review pass found nothing. Green tests prove the code does what the tests check; a clean review proves it's actually right. Only after the loop converged did a change set get manually tested in the running client, and only then merged, before moving on to the next task.
How it was actually run
I didn't hold this whole thing in my head. The migration was driven by a single plan document that doubled as a state store (every module's status, every decision, every deferral) and an execution engine (a fixed per-module recipe and a "find the next task" router). Each work session, most of them run by an AI agent, started cold, read the plan, picked the next unstarted module, did exactly one PR's worth of work, and stopped at a human merge gate. The plan and the merged PRs were the handoff between sessions: no meeting, no lost context, and several lanes safely in flight at once across the team.
That mattered more than it sounds, because of how I worked on it: offline Claude sessions from my phone, checking in when I had a spare moment, nudging a plan or approving a step in a queue, then testing the night's PRs after the kids were asleep. A migration that lives entirely in the repo (plan plus merged PRs) is one you can drive in fifteen-minute slices without ever rebuilding context. That's a whole post of its own, but it's the reason a migration this size stayed coherent.
Results
The backend is 100% Rust: a single evolution-server binary serving HTTP and WebSocket over axum/tokio.
The only JavaScript left is the browser client, which was never touched.
Behaviour is preserved end to end: same seed, same world, before and after, with every deliberate deviation recorded in an ADR.
Every step was verified against the original contract, reviewed to convergence, and signed off by a human before merging into the long-lived Rust branch. Production was cut over to Rust exactly once, at the end, in a single restart.
The simulation that's running right now, as you read this, is the Rust one. You can't tell, and that was the entire point.
Control has always been part of the plan, and it's now in place. Watching an ecosystem from above gives you scale. Following a single organism gives you its story. Controlling one hands you its life: the hunger, the threats, the search for food, the chance of a mate, the inevitability of death, all of it yours to navigate inside a body that has to play by the same rules as every organism around it.
When you take control, you stop being an observer. You're no longer following a creature you're rooting for; you're now responsible for its life and future. Every decision the organism would have made on its own is now yours to make instead. Walk towards some lush trees to eat. Approach another of your species, or keep your distance. Dash away from a group of menacing creatures of another species. The ecosystem doesn't pause, soften, or tilt in your favour. It just keeps running, and now you're in it.
Pressing an organism cycles through three states. The first press begins a follow and a green ring appears around it. A second press on the same organism upgrades to control and the ring turns orange. A third press releases it back to autonomous life. Pressing the background returns to the neutral state from either mode.
Under the surface, the controlled organism's position syncs to yours. The existing scroll and swipe input that already moved the viewport is now also the steering: as your position shifts, the organism copies it and turns to face the direction of motion. No new message types were needed, and no new input layer. The existing client prediction and server reconciliation carry it.
Control is exclusive. Only one player can control a given organism at a time, while follow remains open to anyone. To prevent someone from holding an organism hostage by wandering away from the keyboard, the server tracks when each controller last moved. After 30 seconds of inactivity, control is quietly downgraded back to follow. The ring shifts from orange to green, the organism resumes its own decisions, and the slot is free for someone else.
Nothing about the simulation bends around the controlled organism. It still has to eat to keep its energy up. It can still lose a fight, starve, catch a disease, die of old age. Its offspring still inherit genetics through the same averaging and mutation as any other pairing, and whether its lineage continues depends on survival and reproduction like every other organism's. Control doesn't put you above natural selection. It drops you into it.
Because the rules don't bend, your choices carry. An organism that eats more often survives longer. One that survives longer has more chances to reproduce. One that reproduces more puts more of its genetics into the next generation. Lead it to food and its lineage gets a chance. Walk it into a fight it can't win and its lineage ends there. The mate you find for it is the other half of its offspring's genes, and those offspring then live their own lives, mate with organisms shaped by other choices, and carry traces of your decisions forward through generations. Continue playing as those children, or grandchildren. Anything is possible.
You aren't steering evolution. Evolution is just what happens downstream of choices like these, and the difference now is that some of those choices are yours to make.
Following organisms by clicking them works well once you're looking at the simulation, but it requires finding an organism to click first. On a large world with organisms scattered across thousands of positions, this can take time. They can also be hard to catch sometimes. I wanted a faster way to jump into observation, to start following an interesting organism immediately without searching.
I added a "follow random" feature. When you're not currently following anything, the "following" label becomes clickable. Click it and you're instantly following a randomly selected motile organism, wherever it happens to be in the world.
The server implements this using reservoir sampling, an algorithm that selects a uniformly random element from a collection in one pass, using constant memory regardless of population size. The simulation maintains a cached random motile organism, updated each iteration, so responding to a follow random request is instantaneous.
When the server receives the follow random message, it retrieves the cached random organism and establishes a follow relationship, just as if you had clicked on that specific organism. Your viewport immediately centers on it. If the organism dies, the standard cleanup behavior applies and you stop following.
The implementation handles two distinct cases: following a specific organism you clicked, versus requesting any random organism. When you click the label, the client sends a "follow random" message. The server picks a random organism and establishes the same follow relationship as if you had directly clicked that organism. Both interaction types flow through the same code paths, with type safety ensuring consistent handling throughout.
The feature improves exploration significantly, especially for new visitors. Instead of navigating through the world looking for something interesting to follow, you can immediately jump into tracking an organism's life: see where it goes, what it does, how it interacts with others. If it's not interesting, stop following and request another random organism.
It's a small usability enhancement, but it removes friction from the primary way people explore the simulation, by following individuals and observing their behavior over time.
The simulation's double-buffer state architecture had a performance bottleneck. Every iteration, it rewrote the position of every entity in the world, thousands of organisms and environmental objects, into a spatial index. This happened regardless of whether an entity had actually moved.
Sessile organisms like plants never move, yet their positions were being rewritten every single tick. Dead organisms weren't moving either, but their positions were still being updated. Even motile organisms that decided to stay in place for that tick would trigger position updates.
I was using a double-buffer implementation where all positions from one buffer were copied into the other, then the buffers swapped. This ensured determinism, all reads happened from a stable buffer while writes went to the other, but the cost was O(N) writes every iteration for all N entities.
The fix was to replace double-buffering with a single persistent spatial index and a deferred update queue. When an organism decides to move, instead of immediately updating the spatial index, the change is queued. The queue stores each entity's "from" position and "to" position.
At the end of the iteration, after all organisms have made their decisions, the queued updates are applied atomically. Each relevant entity is removed from its old position in the spatial index and added to its new position. Then the queue is cleared.
Organisms that don't move never queue updates. Sessile organisms, often the majority of the population, cost nothing. Dead organisms that remain positioned during decay cost nothing. Only organisms that actually move create entries in the update queue.
The performance impact is substantial. In a simulation with 1000 entities, maybe 200 motile organisms move while 800 sessile organisms stay in place, an 80% reduction in position writes.
Determinism is preserved because all position queries read from the frozen spatial index during iteration. No entity sees updated positions until the deferred updates are applied at the end, after all decisions are made. The simulation remains fully deterministic, order-independent, and correct.
The change was entirely internal. External consumers see no difference in behavior, only improved performance. It's the kind of optimization that makes scaling possible, reducing unnecessary work without changing what the system does.
Following an organism gives you its perspective, but it doesn't tell you much about the organism itself. I wanted a way to see detailed information: family relationships, health, age, fears, diseases, pregnancy status, in addition to the sparese information in the label.
I added an inspect organism panel that displays comprehensive data about the organism you're following or controlling. Press O or click on the "Following X" label to toggle the panel. It shows the organism's name, species, parents, siblings, children, generation, energy level, age category, fear level, diseases, and pregnancy information.
Family member names are clickable. Click on a mother's name and the panel immediately switches to showing that organism's details. Click on a sibling, a child, the organism's mate - any family connection becomes a navigation point. This lets you explore family trees and track lineages across generations.
The panel updates automatically every 10 seconds with fresh data. The server tracks which organism each session has requested inspection for and broadcasts updates only when the data changes, using etags to avoid redundant transmissions.
The architecture separates concerns cleanly. Server-side utilities extract and minimize organism data. Client-side utilities transform that minimized data into human-readable formats with clickable references. A generic info display component renders the data as DOM elements, handling clickable values and arrays without knowing anything about organisms specifically.
Inspecting organisms changes how you explore the simulation. You can follow an organism, see its entire family history, click through to its parents and see their histories, and follow lineages forward through children and grandchildren. You can watch fear levels rise and fall as organisms encounter threats. You can see diseases spreading through family groups. You can verify pregnancy status and track when offspring will be born.
It transforms observation from passive watching to active exploration. The simulation's data becomes directly accessible rather than hidden behind the visual representation.
Organisms in the simulation have individual names, belong to species, can be infected with diseases, be pregnant, following or being followed. But this information wasn't visible. You could watch an organism move across the world without knowing anything about it beyond its appearance.
I added labels that display directly above organisms. Each label shows the organism's individual name and species name. Additional indicators appear based on the organism's current state: a symbol for pregnancy, another for infection, another for interaction state.
Labels update in real time as an organism's state changes. When an organism becomes pregnant, the indicator appears. When it gives birth, the indicator disappears. If it becomes infected, that status shows immediately.
This makes the simulation much easier to understand. You can see family relationships, organisms with similar species names are possibly related. You can track disease spread through populations by watching infection indicators move through groups. You can identify which organism you're following at a glance.
The labels also make it easier to find specific organisms. If you want to follow a particular species or individual, you can scan the visible organisms and identify them by name rather than appearance alone.
Adding labels required balancing information density with visual clarity. Too much text clutters the viewport. Too little leaves observers without context. The current implementation shows essential information concisely, name, species, and state indicators, without overwhelming the view.
It's a simple addition, but it transforms observation from watching anonymous sprites move around to watching individuals with identities and stories.
When organisms died in the simulation, they vanished instantly. One moment they were there, the next they were gone. This created a jarring experience, you couldn't tell when or why an organism died, and you couldn't see the patterns of death across the ecosystem.
I changed death to be gradual and visible. When an organism dies, it now remains in the world for a while, visually decaying over time. Motile organisms tilt to the side, as if fallen over, and gradually darken. Sessile organisms simply darken in place. Eyes close on dead motile organisms. A death indicator appears in the organism's label.
The decay is tracked over adjustable time, currently 50 iterations. Each iteration increments the decay value, and the visual effects intensify accordingly. After reaching full decay, the organism is removed from state entirely. As before, its energy is transferred to the soil, made available for plants to consume.
This change required coordinating state between server and client. The server tracks each organism's death decay value and includes it in broadcasts. The client reads the value and applies appropriate visual effects—rotation, darkening, closed eyes.
Both pooled and non-pooled rendering paths support death effects. Movement and interaction systems skip dead organisms while keeping them positioned in the world. The visual effects are applied idempotently to prevent duplicate transformations if an organism is rendered multiple times during decay.
The impact on understanding the simulation is significant. You can now see where organisms are dying: starvation clusters in food-depleted areas, death along territorial boundaries between species, mass die-offs during disease outbreaks. You can watch predation events complete rather than having the prey vanish mysteriously. You can see the lifecycle close rather than experiencing it as a sudden disappearance.
Death statistics also shifted from tracking only cumulative totals to including current dead count. This gives a clearer picture of ecosystem health. A stable population maintains a consistent rate of deaths matching births, while a collapsing population shows deaths accumulating.
It's a more realistic representation of how ecosystems work. Death is visible, gradual, and part of the ongoing cycle rather than an instant state transition.
The evolution simulation generates a constant stream of data: births, deaths, species counts, population dynamics. This data was always available inside the simulation, but visitors to the site had to enter the full interactive experience to see any of it.
I wanted visitors to see the simulation's state on the webpage, before deciding whether to jump in. So I built a dedicated WebSocket server that broadcasts simulation statistics to the front page in real time.
The stats server is lightweight and separate from the main state server that handles game clients. It connects to the simulation engine, receives stats every 5 seconds, compresses the data, and broadcasts it to all connected clients. When a new visitor opens the homepage, they immediately receive the current stats without waiting for the next broadcast cycle.
On the client side, a stats display component cycles through different formatted views of the data. Total organisms with animal/plant breakdown. Births, deaths, concurrent generations. Species count. Dominant species for both animals and plants. The display fades smoothly between stats every 4 seconds.
The architecture keeps everything decoupled. Stats clients and game clients don't interfere with each other—they connect to separate endpoints. The stats broadcast logic is isolated from game state logic, making both easier to reason about and scale independently.
The result is a homepage that feels alive. Visitors see the simulation evolving in real time, species appearing and disappearing, populations rising and falling, without needing to enter the world themselves. It gives immediate context about what's happening inside before deciding to explore further.
Survival in nature involves avoiding danger. In the simulation, organisms needed a way to recognize and flee from threats, specifically, larger organisms that might be dangerous.
I implemented a fear-driven behavior where organisms detect nearby organisms significantly larger than themselves. If a detected organism is at least 1.5 times their mass and from a different species, the smaller organism has a high chance of fleeing. If the larger organism is from the same species, there's a smaller chance, about 7.5%, to flee, modeling occasional avoidance of dominant members within a species.
The system filters out organisms that aren't actually threatening. Pregnant organisms aren't perceived as threats, they're vulnerable. Low-energy organisms aren't threatening either, they're weak. And family bonds override fear entirely: organisms never flee from their parents or children, regardless of size difference.
When an organism decides to flee, it calculates the centroid of all nearby threats and moves in the opposite direction. The system uses weighted direction selection to maintain momentum - organisms don't just move randomly away, they continue fleeing smoothly in a consistent direction.
Fear also creates a form of memory. When an organism flees from a position, that position is added to its recent position history with a penalty. This prevents the organism from immediately wandering back into the danger zone after fleeing. The penalty decays over time as the position ages out of the organism's memory.
The result is visible in the simulation. Smaller organisms maintain distance from larger ones. Larger organisms naturally dominate areas. Groups stay cohesive because smaller members avoid straying into territories occupied by larger organisms of other species. There's no explicit territorial behavior coded, it emerges from individual fear responses.
It's a simple threat-detection system, but it creates complex spatial dynamics across the simulated ecosystem.
Organisms were oscillating. They would move one position toward food, then immediately move back the next tick, move toward a mate, then reverse course. The movement logic evaluated each tick independently, with no memory of where the organism had just been or which direction it was already moving.
This ping-ponging created unnatural, jerky movement that made the simulation feel mechanical rather than alive.
I implemented two mechanisms to solve this. First, directional momentum. The existing momentum system that weighted random movement to continue in the same direction was extended to all movement types. When an organism moves toward a goal or away from a threat, it now prefers to continue in roughly the same direction rather than making sharp turns. The weighting is exponential: continuing straight ahead is most likely, adjacent angles are less likely, and reversing direction is least likely.
Second, short-term position memory. Each organism now tracks the last 8 positions it visited in a circular buffer. When evaluating potential moves, recently visited positions receive penalties that decay exponentially. The most recent position gets about a 70% penalty, making it very unlikely the organism will immediately return there. Older positions have weaker penalties, eventually aging out of memory and becoming acceptable again.
These penalties are probabilistic, not absolute blocks. An organism can still return to a recent position if the situation demands it, if food is there or if fleeing danger requires it, but there's a strong behavioral preference to keep moving forward rather than backtracking.
The combination creates movement that feels organic. Organisms trace continuous paths, avoid constantly reversing course, and exhibit spatial awareness of where they've recently been. They still respond appropriately to goals and threats, but they do so with momentum and memory rather than instant reactions.
The change was technically and mathematically straightforward, a circular buffer and some weighted probability adjustments, but the behavioral impact is substantial. Movement looks and feels natural now.