What it was
An ecosystem you watched, not one you played
Wildwood had no controls beyond a camera. You could pan, zoom and click an animal to inspect it. Everything else happened whether you were looking or not.
The world was a fixed isometric diamond in the style of an old strategy game: 9,216 tiles of grass, forest, dirt clearing and pond, generated procedurally from a single seed. Two species lived in it. Deer grazed grass, walked to water, formed loose leaderless herds, bred in the autumn rut and raised fawns that grew to full size over a year. Wolves lived in tight family packs of four to twelve, led by the oldest animal, held a home territory, hunted the deer, fed together at a carcass, and fought other packs' leaders at the borders.
Neither species was scripted. Each animal ran a small state machine over its own hunger, age, position and neighbours, and the population-level behaviour, herds drifting, packs splitting, booms and crashes, was whatever fell out of those rules. Most of the project's life was spent discovering that what fell out was rarely what we expected.
What we learned · large ecosystems
A small world cannot hold a predator and its prey
The original map was 24×24 tiles with fourteen deer and six wolves. It never once reached a stable balance. Every tuning attempt produced the same two failure modes in a loop: give the wolves enough reach and they overhunt, crash the deer and then starve; take reach away and they simply starve while the herd, which always has a refuge somewhere on the map, grows unchecked. Exhaustive parameter sweeps confirmed it. On a map that small, coexistence was not a tuning problem at all.
What fixed it was not a better number, it was space. Scaling the grid to 96×96, sixteen times the area, with every population, cap and terrain feature scaled to keep density constant, changed the behaviour qualitatively. A large map does not hold one population; it holds many partly-independent sub-populations that fall out of sync with each other. When wolves crash the deer in one corner, deer from a distant corner drift back in and repopulate it. Ecologists call this the metapopulation rescue effect, and it is the difference between a system that oscillates and one that dies.
Stability in an ecosystem is a property of scale, not of parameters. We spent days tuning escape chances and hunger rates on a map that was structurally incapable of supporting the answer. Sixteen times the area did what no amount of balancing could.
Once scale provided the stability, the tuning finally became meaningful, and the window turned out to be
narrow. At the large scale a per-lunge escape chance of 0.90 with a 12-tile hunting range starved every wolf
pack out within about 160 in-game months, because a pack's reach covered only an eighth of the map and the
deer always had somewhere to be. Push the other way, 0.80 escape or a 24-tile sense radius, and the failure
flipped to overhunting: the wolves ate the herd down and then starved on the empty map. The values that let
both species persist past 180 months across every seed sat between them: escapeChance 0.85 and an
18-tile sense radius.
And then it died anyway
That is the tuned, verified answer, and it held for the fifteen simulated years the sweeps covered. The live world ran for 443. Here is how it actually ended:
Over a long enough run the predators went extinct regardless, and the deer expanded until grass, not wolves, became the thing that limited them. The herd settled into a population held down by starvation at the carrying capacity of the map: nature's least dignified equilibrium, and a real one. A balance that survives fifteen years and fails at four hundred is not a balance, it is a slow transient, and only running the thing continuously for weeks revealed the difference.
27 fawn(s) born this spring.
A deer starved.
A deer starved.
A deer starved.
33 fawn(s) born this spring.
What we learned · wolves
Hunting is cheap. Eating is the hard part
Almost every wolf problem we hit turned out to be about the meal rather than the chase.
Wolves kept starving next to their own kill
For a long stretch every wolf pack in every seed died out within about sixteen in-game months, leaving a deer-only map. The cause was not the hunt. When a pack made a kill the whole pack ran to share it, hunger kept draining during that run, and the monthly starvation check killed the stragglers a few tiles short of the carcass. Every single wolf starvation death was happening while the wolf was already committed to a meal. Exempting a wolf that is en route to a kill flipped the species from reliably-extinct to reliably-surviving. One conditional.
They also had to be told to look for food
The original wolves roamed their territory and hunted whatever wandered into range, which sounds reasonable and starves them, because the deer are actively fleeing anything wolf-shaped. Three changes made a pack self-sufficient: a hungry wolf seeks the nearest deer it can sense rather than waiting; a chase is persistent, harrying the deer for up to five lunges instead of quitting after one miss; and a no-progress watchdog breaks off a hunt that has got stuck sliding along a pond bank.
The persistence detail matters more than it looks. A real deer escapes any individual wolf lunge about 80 to 90 percent of the time, which is the figure we used, and if a chase is a single lunge then that is also the wolf's success rate per chase, which is far too low to live on. Five lunges per chase turns an 85 percent per-lunge escape into roughly a 41 percent chance the chase ends in a kill. The realistic number and the survivable number are the same number, viewed at different scales.
Eight wolves, one carcass
Originally every wolf targeted the exact corpse tile, shoved the others off it and orbited the spot forever. Some never actually ate, which quietly starved the pack; when we fixed the shoving so all of them fed, the pack became too well fed and crashed the deer. The eventual design: up to six wolves feed at once standing in an evenly spaced ring around the body, ranked oldest-first, while the rest linger further out and move in as slots free. The ring is derived from age rank every tick, so it needs no state and stays deterministic. Worst observed circling at a kill dropped from over twenty minutes to under a second, and the share of feeds that actually completed went from roughly 17 percent to about 90.
The immortal wolves
A favourite bug. A feeding wolf is exempt from starvation, so a wolf that could not reach an awkwardly-placed carcass would freeze in place, permanently, and being technically mid-meal it could never die. These zombie wolves were padding the population count and hiding the fact that the pack could not sustain itself. Removing the freeze made the ecosystem look dramatically worse, because we were finally measuring it honestly.
A bug that props up a number is worse than a bug that breaks one. For weeks the wolf population looked healthier than it was because some of its members were unkillable. Every balance conclusion drawn in that period was wrong.
The rest of wolf life came from the same instinct to model the animal rather than the outcome: a hunger bar that holds full for a month after a kill and then glides to empty over eight, giving a pack roughly a ten-month window to make the next one; a 10 percent chance per lunge of picking up an injury that proves fatal one to four months later; territorial leader fights that are fatal half the time; and packs that split in two when they outgrow twelve members, the younger half taking a new territory. That last rule is what let wolves spread across the big map instead of one pack growing without bound.
What we learned · deer
A prey animal is a scheduling problem
A deer wants four incompatible things: eat, drink, stay with the herd, and not be eaten. Almost every deer bug was a priority-ordering bug in disguise.
The clearest example: deer were starving while standing next to grass, because a deer that was due its monthly drink would walk to water even when its hunger bar was nearly empty, arrive, drink, and be due another trip before it ever got round to eating. The fix is a strict order, flee first, then eat if hungry, then drink only if not hungry, then rejoin the herd, then wander. Thirst can never outrank hunger, and the herd can never outrank either.
Feeding also had to change shape to look right. Deer originally ate once a month when nearly starving, which is nothing like a real deer. Now a deer starts looking for grass once its hunger drops below 0.7, and one blade is only a partial meal, so a hungry deer chains two or three adjacent blades in a short bout with a fraction of a second between bites. Grazing roughly doubled and, counter-intuitively, the herd got healthier: more survivors and fewer starvations, because animals were topping up continuously instead of running their reserves to zero and gambling on finding food in time.
Fleeing, in two tiers
A wolf within four tiles makes a deer bolt directly away in a side-to-side zig-zag, holding each escape heading for about sixteen frames before re-picking so the weave actually shakes a pursuer. A wolf within two tiles means the deer is being chased and sprints. Tuning the sprint against the wolf's chase speed is the single most consequential number in the whole simulation: for a long time a sprinting deer was slightly faster than a chasing wolf, which reads beautifully, produces long tense zig-zag pursuits, and starves every wolf on the map. Making the sprint marginally slower than the chase, and putting the deer's survival into the per-lunge escape roll instead, kept the drama and fixed the ecology.
Herd movement produced its own long tail of small human-looking failures. Two deer meeting head-on did a rapid "sidewalk dance" of mirrored swerves until we gave the avoidance a consistent tangential term, so both animals rotate the same way and therefore pass on opposite sides. Deer trailing single file formed "centipedes" that no amount of repulsion could separate, because the repulsion pointed backwards and merely braked them into a queue. And two deer about to pass now resolve who yields by a fixed right-of-way rule, adults outrank fawns, so exactly one of them gives way instead of both.
What we learned · isometric game development
Everything is depth sorting, and then it is angles
An isometric world is a 2D scene pretending to have a third dimension, and the pretence is maintained
entirely by drawing order. Wildwood sorted by (col + row), painting back to front. Two things
broke that were both really the same thing.
First, animals took their depth from their integer tile coordinates, which jump by a full sort-step partway through a move while the sprite itself glides continuously, so a deer would visibly pop in front of or behind scenery mid-stride. Depth now comes from the animal's continuous foot position. Second, and more fundamentally, the flat floor was competing in the same depth space as the things standing on it: a grass tile one row further south always outranked a deer and drew over its feet. The fix was to give the entire flat layer, terrain, shoreline blends, water, grass tufts, its own band strictly beneath everything that stands up, leaving only tall objects like trees to genuinely sort against animals.
The same class of mistake hit the ambient layers from the other direction: clouds, birds and the night tint were pinned at fixed depths of 7,000 to 9,000, which had been comfortably above everything on the small map. On a larger grid the world's own depths climbed past 45,000 and the sky quietly went underground. Anything derived from the grid's size must be computed from the grid's size.
The deer that walked like crabs
The best bug in the project. Deer are rendered from pre-baked sprite sheets, sixteen directions at 22.5° steps, produced by a headless Blender script that turns a 3D model on a turntable under an orthographic camera matched to the isometric projection. The yaw for each direction was originally derived by eyeballing a calibration render and fitting a line through it. The model's front and back read ambiguously at that angle, so the fit came out mirrored top to bottom: left and right were correct, but north faced the camera and south faced away. Deer moved diagonally at odds with their own bodies, sidling like crabs.
Replacing the eyeballed fit with the actual inverse of the isometric projection, solving
β = atan2(−sin(elev)·cos(ψ), sin(ψ)) for the armature yaw, fixed every direction at once
and made the diagonal foreshortening exact rather than linearised.
Compute the angle, do not eyeball it. A visual calibration that looks plausible can encode a reflection, and a reflection is invisible in every static frame and obvious the moment something moves.
Motion, it turned out, is where an isometric world either convinces or does not, and almost none of that is the artwork:
- Nothing may turn instantly. Facing rotates toward the direction of travel at a capped rate, so a reversal reads as a walked U-turn through the intermediate facings rather than an axis flip. A calm animal turns at 120°/s, one heading somewhere specific at 240, and only a panicking animal snaps round at 540.
- Slow turning needs guard rails. Rate-limited turning creates a minimum turning circle, and a goal inside that circle gets orbited forever. That needs an explicit capture rule, plus a "pin-breaker" that idles an animal which has been milling in one small area too long.
- Corners want curves. The simulation path is a chain of straight tile-to-tile legs, but the client renders position along a Catmull-Rom spline through the surrounding samples, so animals arc through corners rather than pivoting on the spot. Purely cosmetic, entirely worth it.
- Crowds need personal space. A soft mutual push-apart of about half a tile, applied gently over many ticks, is the difference between a herd and a pile. It has to be a cushion, not a shove, and it must exempt anything that is deliberately holding still.
- Arrival radius must scale with speed. A per-tick step larger than the waypoint arrival radius means an animal can straddle its waypoint and oscillate across it forever, never landing inside. This produced deer that vibrated in place for over two minutes.
What we learned · servers and clients
One world, many windows
Wildwood began as a simulation running in the browser, which means every visitor got their own private forest. Making it a shared world meant moving the entire simulation to an authoritative server and demoting the browser to a pure viewer that renders what it is told and simulates nothing.
The rule that made it possible was a hard architectural boundary: nothing in the simulation or shared layers
may import the game engine, touch window, or reference a sprite. That single constraint is what
lets identical simulation code run under Node on the server and in the browser, and it has to be enforced
from the first commit, because retrofitting it means untangling rendering from behaviour everywhere at once.
Determinism is a feature you maintain, not one you have
The server ticked at a fixed 15 Hz and broadcast at 10 Hz, with all randomness drawn from one seeded generator and no wall-clock time anywhere inside the simulation. The point of that discipline is that the world can be snapshotted to a database and restored bit-identically.
It broke silently, twice. Once when a batch of new behaviour, continuous headings, anti-oscillation tile memory, chase and feed watchdogs, feeding-ring anchors, added in-flight state that the snapshot did not persist; on restore each field reset to its default, the first tick consumed randomness differently, and the restored world diverged from the original within a single tick. And once when the map was scaled up: the server happily restored an old 24×24 snapshot into the new 96×96 world, because the seed still matched, so the live site showed a couple of dozen animals huddled in one corner of an otherwise empty map. Snapshots now record the dimensions they were taken at and are discarded unless both seed and size match.
Latency that only ever went up
The status readout showed a round-trip time that climbed without bound and never recovered, which looked like a network problem and was really a backpressure problem. The server pushed a full world snapshot every 100 ms down the same socket the latency ping used. A viewer that could not drain that fast accumulated an ever-growing send queue, so the measured round trip could only rise. Skipping the periodic frame for any client that has not drained the previous one bounds it: every viewer gets the freshest frame it can actually accept, and the client's interpolation buffer smooths over the gaps.
The other half of a smooth viewer is deliberately watching the past. The client renders about three seconds behind the server so it always has frames on both sides of the moment it is drawing and can interpolate rather than extrapolate. That in turn creates its own subtle bugs: grass was visibly shrinking before the deer eating it arrived, because plant state was applied from the newest frame while animals were drawn on the delayed clock. Every piece of state a viewer renders has to be played back on the same clock.
The bill was the real lesson
Broadcasting the full world ten times a second is trivially cheap on localhost and, it turns out, the most expensive thing in the entire system once it crosses a metered load balancer. In its final full month the Wildwood load balancer moved 715 GB, essentially all of it snapshots pushed to a handful of browser tabs. Data transfer alone cost more than the compute, database, registry and DNS put together, by a wide margin. A single browser tab left open on this page all month generates on the order of a terabyte.
Design the wire format under the assumption someone will leave the tab open forever. Full-state broadcast is the easy, correct-by-construction choice and it scales its cost with viewer-seconds, not with activity. Deltas were an optimisation we always meant to get to, and the invoice got there first.
Why it is gone
About $95 a month to watch deer starve
Wildwood ran on AWS Fargate behind a dedicated load balancer, persisting to DynamoDB, in
eu-west-2, deployed from GitHub Actions. All of it was always on, because an authoritative
world has to tick whether or not anyone is watching. That is the point of the design, and it is also the
whole cost problem.
| Line | Approx. per month | Why |
|---|---|---|
| Load balancer data transfer | ~$55 | 715 GB of world snapshots pushed to viewers |
| Load balancer hours + capacity | ~$24 | A dedicated ALB, running 24/7 |
| Fargate task | ~$8 | 0.25 vCPU / 0.5 GB, never idle |
| DynamoDB writes | ~$5 | A world snapshot every 15 seconds |
| Public IP, registry, logs, DNS | ~$6 | Fixed overheads |
| Total | ~$95–115 | Including tax |
That is a lot for a finished experiment nobody is actively developing, and notably more than the roughly $45 a month we assumed before actually reading the bill: the data transfer line was invisible in the mental model and turned out to be the largest single item. The simulation, the artwork, the sprite pipeline, the tests and every lesson above are preserved in the repository and in this page. The part that is switched off is the part that was quietly moving three quarters of a terabyte a month to an audience of nobody.
This page now sits on the same domain, served as static files from S3 and CloudFront, for a few cents a month. Wildwood ran from 7 June 2026 to 27 July 2026: seven weeks of wall time, 443 in-game years, and about 4.7 million ticks. It ended the way real closed ecosystems tend to, with the predators gone and the survivors pressed up against the limits of their food.