OS-027 · Agents & cellular automata

Predator–Prey Simulation

Every sheep and every wolf here is a separate agent with its own position and energy. Nobody writes down an equation for the populations — yet they rise and crash in cycles, lag each other by about a quarter turn, and sometimes one species simply runs out.

Interactive model

OS-027 · predator-prey · seeded
grasssheep (circles)wolves (squares)
path (fades with age)nowstart
sheepwolvesgrass cells ÷ 4
Parameters
Placed at random cells
Chance a living sheep splits its energy with a lamb each step
A wolf loses 1 energy per step
Steps before a grazed cell is green again
Same seed, same run — here, in simulation.js and in simulation.py.
Sheep at the end
364
Wolves at the end
73
Grass cover at the end
56%
Peak sheep
748
Peak wolves
327
Extinction
none
Cycle period (steps)
241
Wolf peaks lag sheep by (steps)
67
Agent cap (2,500) reached
no

Example run: default parameters, seed 42.

Why the populations cycle

The cycle is a feedback loop with a delay. When sheep are plentiful, a wandering wolf often lands on a cell with a sheep, gains energy and breeds. The growing pack eats sheep faster than they are born, so the sheep crash. Now most wolves find nothing, burn through their energy and starve — and with few wolves left, the sheep and the grass recover. The delay comes from energy: a well-fed wolf can survive many lean steps, so the wolf peak arrives well after the sheep peak.

  • The time series shows the lag directly: each wolf peak follows a sheep peak. The lab estimates the cycle period from the autocorrelation of the sheep count and the lag from the cross-covariance of the two series.
  • The phase plot turns the lag into a loop. Sheep grow while wolves are scarce (the path moves right), wolves grow while sheep are plentiful (up), sheep collapse (left), wolves starve (down): a counterclockwise circuit that never closes exactly on itself.
  • The grid shows that the model is spatial: grazed patches, sheep clusters where grass has regrown, and wolves thinning out a crowd.

Change the seed. Most runs cycle, but in some the wolves hit zero in the first deep trough and never return. Nothing in the rules forbids that; with finite numbers of animals, a bad run of luck at a low point is permanent.

The rules

The world is a 51 × 51 grid that wraps around at the edges (a torus). Each cell has grass or is grazed bare. Every step runs in this order:

  1. Sheep, in list order: move to a random one of the 8 neighboring cells, lose 1 energy, eat the grass on the new cell if there is any (+4 energy, the cell becomes bare), die if energy is negative, otherwise give birth with the sheep birth probability. A newborn takes half its parent's energy.
  2. Wolves, in list order: move the same way, lose 1 energy, eat one sheep on the new cell if there is one (+ the energy-per-sheep parameter), die if energy is negative, otherwise give birth with the wolf birth probability, splitting energy the same way.
  3. Grass: a grazed cell turns green again after the regrowth time.

Initial animals are placed at random with random energy; half of the cells start with grass. Births stop while the total number of animals is at the cap of 2,500, so extreme settings cannot grind the browser or the Python port to a halt; the readout says when the cap was hit.

Two back-of-the-envelope balances explain much of what you see. A sheep gains 4 energy per green cell it steps on and spends 1 per step, so it only gains energy while more than a quarter of the cells are green. A wolf spends 1 per step and gains 20 (default) per catch, so it breaks even when roughly one step in 20 lands on a sheep — with 2,601 cells that needs on the order of 130 randomly scattered sheep. Below that the wolves shrink, above it they grow. These are mean-field estimates that ignore clustering, but they mark where the curves turn.

Agents versus the Lotka–Volterra equations

The classic mathematical predator–prey model, the Lotka–Volterra equations, treats populations as smooth numbers that change deterministically. Its orbits are closed curves: start anywhere and the system retraces the same loop forever, with an amplitude fixed by the starting point.

The agent-based version differs in three ways that matter:

  • Demographic noise. Births, deaths and encounters are chance events, so each cycle has a different height and length. The loop in the phase plot wanders instead of repeating.
  • Extinction is possible and final. In the equations a population can come arbitrarily close to zero and recover. Here, a population of zero stays zero.
  • Prey are limited by food. Without wolves the sheep do not grow without bound: grass caps them. Set initial wolves to 0 to see the sheep settle at the level the grass supports.

Things to try: raise the energy per sheep to 40, and the wolves become so efficient that they overshoot, strip the field of sheep and usually starve out. Lower it to 10 and the wolf swings get smaller and the cycles faster. Raise the grass regrowth time to 30 and the sheep become food-limited at a low level where wolves barely persist, and the cycles fade into noise.

Example result

With the default settings and seed 42 the run ends after 1,000 steps with 364 sheep and 73 wolves, and 56% of the cells green. Sheep peaked at 748 and wolves at 327. Extinction: none. The populations cycled with a period of about 241 steps, with wolf peaks 67 steps behind sheep peaks — roughly a quarter of a cycle.

Output of predator-prey with initial_sheep=200 initial_wolves=50 sheep_reproduce=0.04 wolf_reproduce=0.05 wolf_gain=20 grass_regrowth=10 steps=1000 seed=42
Sheep at the end364
Wolves at the end73
Grass cover at the end56%
Peak sheep748
Peak wolves327
Extinctionnone
Cycle period (steps)241
Wolf peaks lag sheep by (steps)67
Agent cap (2,500) reachedno

Implementation

The page runs simulation.js from the package below; the Python file is a line-by-line port with the same random-number generator, so a given seed produces the same result in both languages. The excerpts show the core of each.

JavaScript — simulation.js
// One step: every sheep moves, pays 1 energy, grazes and may give birth; then every
// wolf moves, pays 1 energy, eats one sheep on its cell and may give birth; then
// grazed cells count down toward regrowth. Agents with negative energy die.
export function stepWorld(w, rng) {
  const { p, grass, timer, head, next } = w;
  let room = AGENT_CAP - w.sheep.length - w.wolves.length;
  const sheep = [], lambs = [];
  for (const s of w.sheep) {
    move(s, rng);
    s.e -= 1;
    const c = s.y * SIZE + s.x;
    if (grass[c] === 1) { grass[c] = 0; w.green--; timer[c] = p.grass_regrowth; s.e += SHEEP_GAIN; }
    if (s.e < 0) continue; // starved
    sheep.push(s);
    if (rng.bernoulli(p.sheep_reproduce)) {
      if (room > 0) { const e = Math.floor(s.e / 2); s.e -= e; lambs.push({ x: s.x, y: s.y, e }); room--; } else w.capHit = true;
    }
  }
  w.sheep = sheep.concat(lambs);
  // Index sheep by cell (linked lists) so a wolf finds prey on its cell in O(1).
  head.fill(-1);
  for (let i = 0; i < w.sheep.length; i++) { const s = w.sheep[i]; const c = s.y * SIZE + s.x; next[i] = head[c]; head[c] = i; s.eaten = false; }
  const wolves = [], pups = [];
  for (const f of w.wolves) {
    move(f, rng);
    f.e -= 1;
    const c = f.y * SIZE + f.x;
    if (head[c] >= 0) { w.sheep[head[c]].eaten = true; head[c] = next[head[c]]; f.e += p.wolf_gain; }
    if (f.e < 0) continue;
    wolves.push(f);
    if (rng.bernoulli(p.wolf_reproduce)) {
      if (room > 0) { const e = Math.floor(f.e / 2); f.e -= e; pups.push({ x: f.x, y: f.y, e }); room--; } else w.capHit = true;
    }
  }
  w.wolves = wolves.concat(pups);
  w.sheep = w.sheep.filter((s) => !s.eaten);
  for (let c = 0; c < grass.length; c++) if (grass[c] === 0 && --timer[c] <= 0) { grass[c] = 1; w.green++; }
  w.t++;
}
Python — simulation.py
def step_world(w, rng):
    """Sheep move, pay 1 energy, graze, maybe give birth; then wolves move, pay 1,
    eat one sheep on their cell, maybe give birth; then grazed cells regrow."""
    p, grass, timer = w.p, w.grass, w.timer
    room = AGENT_CAP - len(w.sheep) - len(w.wolves)
    sheep, lambs = [], []
    for s in w.sheep:
        move(s, rng)
        s[2] -= 1
        c = s[1] * SIZE + s[0]
        if grass[c] == 1:
            grass[c] = 0
            w.green -= 1
            timer[c] = p["grass_regrowth"]
            s[2] += SHEEP_GAIN
        if s[2] < 0:
            continue  # starved
        sheep.append(s)
        if rng.bernoulli(p["sheep_reproduce"]):
            if room > 0:
                e = s[2] // 2
                s[2] -= e
                lambs.append([s[0], s[1], e])
                room -= 1
            else:
                w.cap_hit = True
    w.sheep = sheep + lambs
    # Index sheep by cell: the last-listed sheep on a cell is eaten first.
    on_cell = {}
    for i, s in enumerate(w.sheep):
        on_cell.setdefault(s[1] * SIZE + s[0], []).append(i)
    eaten = [False] * len(w.sheep)
    wolves, pups = [], []
    for f in w.wolves:
        move(f, rng)
        f[2] -= 1
        prey = on_cell.get(f[1] * SIZE + f[0])
        if prey:
            eaten[prey.pop()] = True
            f[2] += p["wolf_gain"]
        if f[2] < 0:
            continue
        wolves.append(f)
        if rng.bernoulli(p["wolf_reproduce"]):
            if room > 0:
                e = f[2] // 2
                f[2] -= e
                pups.append([f[0], f[1], e])
                room -= 1
            else:
                w.cap_hit = True
    w.wolves = wolves + pups
    w.sheep = [s for i, s in enumerate(w.sheep) if not eaten[i]]
    for c in range(len(grass)):
        if grass[c] == 0:
            timer[c] -= 1
            if timer[c] <= 0:
                grass[c] = 1
                w.green += 1
    w.t += 1

Run the full model from a terminal:

Shell
node simulation.js --initial-sheep 200 --initial-wolves 50 --seed 42
python simulation.py --initial-sheep 200 --initial-wolves 50 --seed 42

Download the model

predator-prey.zip6 files · 19.2 KBDownload .zip
Files in predator-prey.zip
FileContentsSize
README.mdHow to run it, parameters, example result2.8 KB
model.jsonParameters with ranges and defaults, outputs, the seed-42 example3.2 KB
simulation.jsThe model used on this page plus a command-line runner (Node 18+)12.8 KB
simulation.pyPython 3.9+ port, standard library only11.8 KB
example.csvResult table from the example run15.7 KB
LICENSE.txtMIT License1.0 KB

Assumptions & limitations

  • Random walkers. Animals move to a random neighboring cell with no memory, no sight of food or danger, and no herding or pack hunting.
  • Encounters on one cell. A wolf can only eat a sheep on the cell it moves to, at most one per step. Real predation depends on search, chase and handling time.
  • Asexual, ageless animals. Birth is a fixed probability per step that splits energy; there are no sexes, ages, seasons or maximum lifespans.
  • Fixed-order updates. All sheep act before all wolves in each step, and within each species animals act in list order. Other schedules, such as random order or simultaneous moves, would give different individual runs.
  • Capped population. Births stop while the total number of animals is at the cap of 2,500, which only binds at extreme settings.
  • A toy ecosystem. The model illustrates how cycles and extinctions arise from individual rules; it is not calibrated to any real wolf or sheep population.

References

  1. Wilensky, U. (1997). NetLogo Wolf Sheep Predation model. Center for Connected Learning and Computer-Based Modeling, Northwestern University. ccl.northwestern.edu/netlogo/models/WolfSheepPredation
  2. Wilensky, U. & Rand, W. (2015). An Introduction to Agent-Based Modeling. MIT Press.
  3. Lotka, A. J. (1925). Elements of Physical Biology. Williams & Wilkins.
  4. Volterra, V. (1926). Fluctuations in the abundance of a species considered mathematically. Nature, 118, 558–560. doi.org/10.1038/118558a0