The whole Wildwood map seen from above: a 96 by 96 tile isometric diamond of grass, forest clusters, dirt clearings and scattered ponds.

Retired · 27 July 2026

Wildwood

A forest that ran itself. Deer grazed, drank and raised fawns; wolves hunted in packs, fought over territory and starved when they failed. One authoritative simulation ticked 24 hours a day in the cloud for seven weeks, and everyone who opened the page watched the same animals in the same places at the same moment. It reached in-game year 443 before we turned it off.

443in-game years
96×96tile world
15 Hzserver tick
4.7Mticks simulated
67commits

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.

Several deer standing along a sandy pond shore under trees in the isometric forest.
A herd on the shoreline. Deer stayed loosely together by drifting toward the centroid of their herd, with no leader. If one strayed past six tiles it headed back before doing anything else.

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.

The lesson

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:

A dense crowd of deer covering the forest floor. The heads-up display reads: April, Year 443, Deer 209, Wolf 0, and the event log repeats 'A deer starved.'
The final state, April of year 443. Two hundred and nine deer, zero wolves, and an event log that says nothing but A deer starved. In the last twelve in-game months the world recorded 94 starvation deaths, 4 deaths of old age, and no predation at all.

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.

// the last words of Wildwood, read from the live server before shutdown
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.

A pack of wolves running across open grass in the isometric forest.
A pack on the move. Packs held a nine-tile home territory around a fixed centre. Only the one or two oldest wolves hunted; the rest followed, fed, and inherited the leadership when the leader died.

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.

Wolves gathered at the edge of woodland after a kill, with the event log reading 'Wolves (pack of 8) brought down a deer' and 'Two pack leaders fought, one died'.
After a kill. The whole pack commits to the carcass. Getting eight wolves to share one corpse gracefully took more engineering than getting them to catch it.

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.

The lesson

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.

Three wolves close up, walking along a dirt path through the grass.
Rank decides everything. Leadership, hunting duty and feeding order all derive from age, so no wolf ever stores its own status. The oldest living wolf in a group simply is the leader, which means a leader dying needs no bookkeeping at all.

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.

A single buck with antlers standing over a small tuft of grass, head lowered to eat.
Grazing in place. An eating deer must actually stop, which sounds trivial and was not: the personal-space pass kept nudging a feeding animal off its own meal while the grass depleted underneath it. Feeding animals are now exempt from being pushed, but not from being walked up to.

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.

A deer standing at the sandy edge of a large pond, head lowered to the water.
Drinking from the bank. No living animal may ever stand on water, so a thirsty deer searches for a walkable tile orthogonally adjacent to a pond, and only accepts one it can actually reach by a water-avoiding path, which lets it walk around a pond to a far shore. Each deer also claims its own bank tile, because the first version had the whole herd stacked on one.

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.

The lesson

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.

The lesson

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.

LineApprox. per monthWhy
Load balancer data transfer~$55715 GB of world snapshots pushed to viewers
Load balancer hours + capacity~$24A dedicated ALB, running 24/7
Fargate task~$80.25 vCPU / 0.5 GB, never idle
DynamoDB writes~$5A world snapshot every 15 seconds
Public IP, registry, logs, DNS~$6Fixed overheads
Total~$95–115Including 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.