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.

A camp of coloured tents around a fire on a green hillside, mountains behind, with a minimap in the corner and a heads-up display reading day 2416, autumn, 308 people.
Day 2,416. Autumn. 308 people alive across the island — this is one band's camp, and the figures walking the ridge behind it are on their way somewhere for reasons of their own.

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.

A band's card showing chief, population, store, and fourteen skills with percentage bars, plus a table of who is here and what they are doing.
NAEWEINEK: 24 people, founded on day 2,303, four lost — two to weakness from hunger, one to old age, one to the sickness. Fighting sits at 0%. They have never needed it.
The full island map: a green landmass ringed by blue sea, with around twenty two-letter band markers scattered across it and a one-kilometre scale bar.
Twenty-odd bands on one island. The two-letter code follows a band everywhere — the map, the panel, every line it appears in in the chronicle.

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.

Six seeds, eight simulated years each, counted off the lineage record
 as it was+ sighting rules+ desperate
tigers as a share of all deaths73%44%26%
tiger deaths across six seeds382715
alive at eight years, six bands87105112

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.

should have been on screen
95
actually drawn
24
after one line
88 of 88

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.

A panel titled Running the world on, showing a progress bar, 301 people across 22 camps at year 4.4 of 5, a multi-line population chart, and chronicle entries about skills learned and forgotten and sicknesses reaching camps.
Five simulated years in about fifty seconds, unwatched. One line per band. The chronicle beside it is doing the explaining: Siamom has real skill at quarrying, Drumate has forgotten how to take anything out of the water, a sickness reached Papal.

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

Built with three.js r169 from a CDN. Everything else is in the repository.
The numbers in this post were measured on the versions described, not estimated.

Comments

Popular posts from this blog

Coding On Your Android Phone Using Termux

How to Install Microsoft Office on Your PC: A Step-by-Step Guide

The Hidden Disk Hogs on Windows Developers Keep Ignoring (And How to Move Them to D:)