Hunter Gatherer Tribe Simulation Using Three.js In the Browser
A procedurally generated island, a few hundred hunter-gatherers, and no script telling them what to do. Every constant in it carries the measurement that produced it — including the one where the tiger was eating everybody.
It started as an excuse to play with three.js: procedural terrain, instanced grass, a physical sky on a running clock. That part took a week. The part that took the rest was the people.
There are now up to a thousand of them. They forage where it paid last time, hunt what they can actually catch, come home at dusk, sleep in a hut, fall ill and sometimes recover. They learn fourteen crafts, trade, split off to found new bands when a camp outgrows its fire, bury their dead under cairns you can still find a century later, and starve when the store runs out. Nobody wrote any of that history down in advance. It is a simulation, and what happens is whatever happens.
The whole thing is one HTML file and a folder of ES modules. No build step, no node_modules, no asset pipeline. The server is node:http and node:fs, and that is the entire dependency list.
NAWhat a band knows
Fourteen skills, each one moving a number that already existed
A skill that only shows up on a readout is a readout. Every one of these changes something the simulation was already computing: curing meat means less of the store spoils, tracking means a hunter picks something out from half as far again, fire-keeping means a tiger will not come as close to the fire.
The one worth watching is healing, because of where it comes from. What a band practises is weighted by what it has been worrying about, and the weight on herbs is the share of the band that is ill right now. So a band learns to treat a fever because it has been having fevers — and the bands that are good at healing are the ones that went through something, which you can still read off the card years after the last of them died of it.
TGThe tiger problem
Seventy-three per cent of everybody
A player told me their world had gone extinct and the tiger seemed to be the reason. It was. Eight simulated years on their seed: fourteen deaths, eleven of them a tiger.
The interesting part was why. A comment at the top of the predator code had been saying for months that a tiger "prefers four legs to two… but it will take somebody who is out alone". That was a lovely description of behaviour nobody had implemented. What the code did was score a person at six times their real distance and otherwise treat them exactly like a deer.
Preferring four legs only helps when four legs are in sight. Two hundred animals over a nine-hundred-metre island is less than one animal inside the sixty-two-metre circle a tiger sees a deer in.
So most of the time there was nothing four-legged to prefer, and a person alone in an empty stretch was simply the best thing on offer — visible from as far away as a deer. "Out alone" is now four rules, and the last one does most of the work: a person is not noticed past 22 metres against 62 for a deer; a person with anyone else within 15 metres is not considered at all; and none of it happens unless the tiger's stomach is below desperate, which is well past merely hungry.
| as it was | + sighting rules | + desperate | |
|---|---|---|---|
| tigers as a share of all deaths | 73% | 44% | 26% |
| tiger deaths across six seeds | 38 | 27 | 15 |
| alive at eight years, six bands | 87 | 105 | 112 |
The middle column is why all four rules are there rather than two. Halving the sighting range moved it a long way and still left tigers the leading cause of death, because a hungry tiger with no deer in sight will simply walk until it finds somebody. The hunger gate is what makes that a rare state instead of most of a tiger's week.
They still kill. Fifteen deaths over forty-eight band-years is a tiger worth running from, and on the seed this started from it is still seven of ten deaths — because the hunting is poor on that island, and a tiger that hunts poorly is exactly the one that comes for people. That is the mechanism working, not the mechanism failing.
/* And only when it is properly hungry. `hunts` is when it starts looking. This is how far down it has to be before a person is on the list at all — so there is a stretch, most of its hunting life, where it is hunting and what it is hunting is deer. A stomach falls from full to empty over `lasts` days: it starts looking around day 2.7 and only starts counting people around day 4.8, by which point it has had two days of failing to find anything with four legs. A tiger that hunts well never gets there. */ desperate: 0.22,
src/wildlife.js — this is what most of the codebase looks like
HDThree that were hiding
The bugs you cannot see by looking
The best ones all had the same shape: the world looked fine, and a number that should have been derived was being remembered instead.
Most of the band was never simulated
Past two dozen people they take turns — each step walks one slice of the band and steps over the rest. The counter that decides whose turn it is was only ticked in the unwatched fast-forward. So while anybody was actually looking, it never moved, and the same slice was walked every frame for ever. Everyone else was not merely undrawn; they were never stepped at all — no job, no movement, no ageing, and the zero matrix they were built with that nothing ever overwrote.
It hid because below the threshold the stride is 1 and every index gets visited anyway. It appeared the moment a world grew past two dozen people — as most of a crowd standing perfectly still.
A band that thought it was full
Everything a person decides comes off camp.hunger, which sat on the camp object as a literal 0 — on the same line as food: 0, which says the opposite. An empty store is maximum hunger. The literal stood until the first book-keeping pass an eighth of a day later, and every person picks their first job within six seconds of the world existing. At hunger 0 the weights send 21% of a band outside; at hunger 1, 96%. A brand new band sat down around a fire it had nothing to cook on.
A band relearning what it already knew
Every reload re-announced every skill, because the record of what a band had already been told about itself was derived from its mastery, never saved, and never worked out again on the way back in. All 236 skill lines in one world's history came from six reload bursts — one day produced 72 announcements. Against a ceiling of four rungs on three skills, not one of them was a band learning anything.
TSHow it stays honest
Checks that run in a second
There are 1,276 of them and they read the source as text, which is why they are fast enough to run on every change. They assert things a type system cannot: that a plague reaching a camp and one person catching it are different kinds of event so the chronicle can filter one without the other; that no errand is given a flat sixty-second timeout, because that is how people used to give up before arriving; that every skill has an effect, a craft weight, and a word for forgetting it.
A second suite boots the whole page against a mocked DOM and a stubbed renderer, drives it through years of simulated time, and asks the world what happened. That is the one that catches the bugs above — you cannot see "this person is never stepped" by reading.
Have a look
There is nothing to install. Clone it and start it.
git clone https://github.com/mudiadamz/web3d-openworld cd web3d-openworld npm start # http://localhost:8080 npm start -- --port 8089 # --host, --people, --map too DEER=90 CAMPS=4 SEED=777 npm start
Forty-two environment variables, so a world is a .env file you can hand to somebody else. Node 18 runs the page; Node 22 adds the SQLite chronicle that remembers worlds between reloads.
Read the source
Comments
Post a Comment