Showcase panel 20:kiln.agents: a flock of about 200 and a predator in one Population, steered by separate, align, cohere, evade and pursue; the prey graze a moss Field, pay for living (metabolize), breed (reproduce) and die, and a behaviour tree decides for all of them (hover to see it).
Try it live: run uv run python examples/showcase.py, then hover panel 20, or see the showcase.
The Agents tutorial gave a population senses, steering forces and a behaviour tree. This page puts the population in a world and lets it live there. Agents eat from a field that grows back, pay for living, have children with their genes and die. Nobody sets the traits: the ones that keep agents fed spread, so they evolve. Last, agents get small brains, neural networks whose weights are genes, run for the whole population at once and evolved over generations rather than trained.
Everything stays arrays, as before: a field is a numpy grid, the genes are one (n, g) column of the population, and the brains are one batched pass.
from kiln import agents
Fields: a world that remembers
A Field is a grid of values laid over the world: food, a scent, heat. Agents read it where they stand (sample, gradient), eat from it (take) and leave things in it (deposit). step(dt) lets it spread (diffusion), fade (decay) and grow back toward its cap (regrowth). Here 150 grazers wander over moss and eat what is under them, so they leave trails that slowly grow back.
import numpy as npimport kilnfrom kiln import agentsW, H =480, 300rng = np.random.default_rng(1)moss = agents.Field((96, 60), (W, H), diffusion=20.0, regrowth=0.4, cap=1.0)moss.values[:] =1.0# a full meadown =150heading = rng.uniform(0, 2* np.pi, n)grazers = agents.Population( n, pos=rng.uniform((0, 0), (W, H), (n, 2)), vel=np.column_stack([np.cos(heading), np.sin(heading)]) *30, energy=np.zeros(n),)for _ inrange(180): # three seconds at 60 steps a second grazers.vel += agents.wander(grazers, 60, rng) /60 grazers.step(1/60, max_speed=40) grazers.pos %= (W, H) # the field wraps, and so does the world grazers.energy += moss.take(grazers.pos, 3.0/60) # never more than is there moss.step(1/60)print("moss left:", moss.values.mean().round(3), " eaten:", grazers.energy.sum().round(1))print("under the first three:", moss.sample(grazers.pos[:3]).round(3))def draw(): kiln.background(14, 12, 11) kiln.fill(96, 148, 92) kiln.tint(96, 148, 92) moss.draw() # one pixel per cell, as opaque as the cell is full kiln.circles(grazers.pos, 2.5, (212, 162, 72))show(draw, size=(W, H), jpeg_quality=85)
moss left: 0.864 eaten: 1348.8
under the first three: [0.59 0.698 0.75 ]
take gives every agent what it asked for, or a share of what the cell holds when the agents in it ask for more, and the field loses exactly what they got. So energy is moss, moved around. gradient says which way the field rises, and steering up it is how an agent smells its way to food. A field wraps around its edges by default; edge=agents.FieldEdge.CLOSED walls it in, as the Meadow example below does.
The life cycle
Now each grazer gets genes. A Genome adds a genome column to the population, one gene in [0, 1] per trait, and maps each gene through its range to a trait. Four calls make the life cycle:
metabolize(pop, dt, cost) takes cost * dt of every agent’s energy: a body, a pace and a pair of eyes all cost something.
reproduce(pop, mask, rng=...) gives every agent in mask a child. The child gets share of its parent’s energy and a copy of its genes, each gene changed with odds mutation by a normal step of sigma. Energy is handed over, never made.
die(pop, mask, leave=field, amount=...) removes agents, and their bodies feed a field.
the field’s step grows the food back.
moss.regrowth =0.05# slower: the meadow now feeds a few hundredgrazers = agents.Population( n, pos=rng.uniform((0, 0), (W, H), (n, 2)), vel=np.column_stack([np.cos(heading), np.sin(heading)]) *30, energy=np.full(n, 0.5),)genome = agents.Genome(grazers, {"size": (2.0, 6.0), "speed": (20.0, 80.0)})genome.randomize(rng)print("size genes:", genome["size"][:3].round(2), "-> sizes:", genome.trait("size")[:3].round(2))grazers.energy[:3] =1.2# three well-fed grazersbefore = grazers.energy.sum()children = agents.reproduce(grazers, grazers.energy >1.0, rng=rng)print("children:", len(children), " energy before and after:", before, grazers.energy.sum())print("a parent and its child:", grazers.energy[[0, children[0]]])
size genes: [0.05 0.84 0.92] -> sizes: [2.2 5.35 5.68]
children: 3 energy before and after: 77.1 77.1
a parent and its child: [0.6 0.6]
The three parents gave each child half their energy (share=0.5, the default), so the total is unchanged. Here is the whole life cycle in one loop, run for a minute:
In the picture each grazer is drawn as big as it is and coloured by its speed gene, from deep amber for slow to pale gold for fast, over moss that is as bright as it is thick.
The population settles at a few hundred, where the moss that grows back feeds the grazers that eat it: no extinction and no explosion, though nothing sets the number. And the genes drift down. A bigger mouth asks for more, but take never gives more than the cell holds, and the grazers keep the moss grazed down to between 0.13 and 0.19 of full (the last column). On moss that thin a big mouth gains little, but a big body pays its whole cost every second. Speed costs v² and there is nothing to outrun. So small, slow grazers have more children, and the means fall. That is selection with no one selecting: John Holland’s genetic algorithm (1975), with energy in place of a score, as in Larry Yaeger’s PolyWorld (1994), where creatures lived, ate, mated and died in a world of food rather than being scored.
Evolution you can see
examples/23_ecosystem.py grows this into a Meadow. Fertile patches drift with the seasons (the moss’s cap is a map, one value per cell), grazers carry genes for size, speed and sight and look ahead for thicker moss, and the meadow is walled. Press p and hunters come in: a second kind in the same population, with the same genes scaled up. Grazers run from hunters they see, and hunters eat the smaller grazers they catch. Now speed pays its way.
Here the same meadow, from the same seed, runs twice for 100 seconds: once with the hunters let in after 20 seconds, and once with none. World.run(steps, hunters_at=...) steps it headless. The video is the run with hunters. The histograms on the right are the grazers’ genes, with red dashes where they started.
gene start with hunters without
size 0.52 0.62 0.63
speed 0.46 0.71 0.54
sight 0.49 0.46 0.46
The hunters select for speed, and for speed only: it climbs to 0.71 with them and to 0.54 without. Size climbs either way, to about 0.62. In the Meadow a grazer bites a share of the moss under it, more for a bigger mouth, and a big body costs less than its size (size ** 0.85), so size pays with or without hunters. Sight, which costs energy every second, slips a little in both. Run it with uv run python examples/23_ecosystem.py and press p to let the hunters in or drive them out.
Brains
Steering forces and trees are rules you write. A brain is a rule the genes write. Each agent gets a small neural network, its weights read from its own genes, and everyone’s network runs in one batched pass. Its inputs are what the agent sees, its outputs turn it and set its speed. Networks of a fixed shape have long been evolved by genetic algorithms; an early, widely cited example is David Montana and Lawrence Davis (1989).
Seeing: rays
rays casts k rays from every agent, fanned across fov radians around its heading, and reads how close the first body of each kind lies along each ray: its closeness, 1 - distance / reach. So 0 means nothing in reach and 1 means touching, and a brain’s input grows as the thing it sees comes near. ray_directions gives the same rays as unit vectors, for drawing them.
eyes = agents.Population(4, kinds=("forager", "food"), kind=["forager", "food", "food", "food"], pos=[[80, 150], [190, 150], [150, 70], [60, 260]], vel=[[1, 0], [0, 0], [0, 0], [0, 0]],)seen = agents.rays(eyes, 7, np.pi, 160, kinds=("food",), radii=12, who=eyes.kind ==0)ways = agents.ray_directions(eyes, 7, np.pi, who=eyes.kind ==0)print("closeness along each ray:", seen[0].round(2))def draw(): kiln.background(24, 18, 15) me = eyes.pos[0]for way, c inzip(ways[0], seen[0]): tip = me + way *160* (1- c) # to the hit, or the whole reach kiln.stroke(width=1.5) kiln.fill(*((150, 196, 132, 255) if c >0else (150, 140, 130, 90))) kiln.line(*me, *tip) kiln.circles(eyes.pos[1:], 12, (150, 196, 132)) kiln.circles(eyes.pos[:1], 6, (212, 162, 72))show(draw, size=(300, 300))
closeness along each ray: [0. 0.41 0. 0.39 0. 0. 0. ]
The forager faces right, and its seven rays cover the half of the world in front of it. Ray 0 is the one turned furthest toward −y, the top of the window. Two rays see food: the one straight ahead and the one turned toward the food above. The food off to the side, past the last ray, is out of view, and so is anything between two rays: rays are thin, so use enough of them for the size of what they must see.
Thinking: Brains
Brains(layers) describes the network, such as (16, 16, 4): 16 inputs, 16 hidden, 4 outputs, tanh between them. It holds no weights. Those are genes: give the Genomeweight_count=brains.weight_count extra genes, and forward(genome.weights, inputs) runs every agent’s own network at once. Each gene in [0, 1] is read as a weight in [−1, 1].
That pass is one matmul per layer over all 5,000 networks, and costs about 0.75 ms (0.45 ms with dtype=np.float32 and the genes cast once).
Acting: drive
drive(pop, outputs, dt, max_speed=..., max_turn=...) turns two outputs in [−1, 1] into velocities: output 0 turns the heading by up to max_turn radians a second, and output 1 sets the speed, from standing still at −1 to max_speed at 1. Brains that set a raw force tend to jitter; a turn and a speed read as intent.
Here it is with example 24’s setup: 8 rays toward food and 8 toward hunters make 16 inputs, and a 16 → 12 → 2 brain gives a turn and a speed.
A brain is not trained. A generation lives for a while and is scored, then next_generation(genes, fitness, rng) breeds the next one: the best few (elite) kept as they are, the rest children of tournament winners, their genes crossed and mutated. Tournament selection is compared with the other schemes in David Goldberg and Kalyanmoy Deb (1991); keeping an elite goes back to Kenneth De Jong (1975). Here it finds a hidden target, with the distance to it as the score:
generation 0: best -0.666, mean -1.398
generation 10: best -0.116, mean -0.241
generation 20: best -0.040, mean -0.069
generation 30: best -0.021, mean -0.056
With the elite kept, the best never gets worse. examples/24_neuroevolution.py puts it all together. 240 foragers each have a brain of 16 ray readings in (8 toward food, 8 toward hunters), 12 hidden and 2 out to drive. A forager scores a point for each piece of food it eats and loses two when a hunter catches it. Each generation lives 20 seconds. This is the thirteenth generation from ten seconds in, with the best forager ringed and its rays drawn, and the best (gold) and mean (ash) score of each of the twelve before it on the right.
neuro = example("24_neuroevolution.py")class Thirteenth(neuro.Neuroevolution):def setup(self):super().setup() world =self.worldwhile world.generation <12or world.time <10: world.step(1/60)for g, (best, mean) inenumerate(world.history):if g %3==0or g ==len(world.history) -1:print(f"generation {g:>2}: best {best:.0f}, mean {mean:.2f}")show_video(Thirteenth(), frames=120, size=(1280, 800), width=720)
generation 0: best 17, mean 2.18
generation 3: best 23, mean 4.32
generation 6: best 25, mean 4.99
generation 9: best 23, mean 5.50
generation 11: best 23, mean 6.55
The first generation’s weights are random, so most foragers spin or wander. Within a few generations they turn toward food, and the mean score climbs.
What it does not learn. The hunter rays are wired, so a brain could learn to dodge. Over 15 generations at the defaults it doesn’t: the catches rise from about 25 a generation to about 45, then level off, while the mean score climbs. A harsher variant, a 5-point penalty with hunters nearly as fast as the foragers, didn’t help over 30 generations either. Eating is easy to stumble on and pays at once; dodging pays only when a hunter is near, and a random brain almost never does it. Evolving it would take longer runs, or a richer world.
What it costs
uv run python bench/agents.py times the step and the brains; the rays were timed apart, as the table says. On an Apple M3 Pro, numpy only, at a load average of 2 to 3.5:
Step
5,000 agents
Gate
One ecosystem step: sense, steer up a 256×256 field, move, eat (take), metabolize, die, reproduce, step the field
about 1.4 ms (fastest about 1.1)
4 ms
One brain pass, 16 → 16 → 4, float64
about 0.75 ms
1 ms
The same in float32
about 0.45 ms
0.6 ms
rays: 4,000 of the 5,000 cast 8 rays × 2 kinds (800 food, 200 hunters, radii 9 and 16) in a 1024×1024 world, reach 60
about 9 ms
The same at reach 140, example 24’s
about 32 ms
The ecosystem step and the brain pass fit in a 16.7 ms frame many times over. The rays are the expensive part, and their cost grows with the reach, since a longer ray has more of the world to search: for 4,000 seers they fit in a frame at reach 60 and take about two at reach 140. Example 24 casts them for 240 foragers, where they are cheap. For thousands of seers, keep the reach short or the rays few.
Where the ideas come from
Steering behaviours: Craig Reynolds, Steering Behaviors For Autonomous Characters (1999).
Genetic algorithms: John Holland, Adaptation in Natural and Artificial Systems (1975).
Neural networks evolved by a genetic algorithm: David Montana and Lawrence Davis, Training Feedforward Neural Networks Using Genetic Algorithms (1989).
Tournament selection: David Goldberg and Kalyanmoy Deb, A Comparative Analysis of Selection Schemes Used in Genetic Algorithms (1991). Elitism: Kenneth De Jong, An Analysis of the Behavior of a Class of Genetic Adaptive Systems (1975).
Creatures that eat, mate and die in a world, with evolved brains: Larry Yaeger, PolyWorld (1994).
For further reading, Daniel Shiffman’s The Nature of Code walks through these ideas in its second half.
Try this
Every name on this page is in the reference, under Fields, Life and evolution, and Brains and generations. To go further, next_generation takes selection= and crossover=, Brains takes activation=, and a FlowField gives agents a current to steer by.
Give the grazers a sight gene and make them steer to the thickest moss they see, as the Meadow does. What does it cost, and does it pay?
Add a second field, a scent the grazers deposit as they walk and decays, and make them avoid it: they will spread out.
Feed the Meadow’s grazers a brain instead of a rule: rays toward moss with fields= and toward hunters with kinds=, and the energy they keep as the fitness.
Score example 24’s foragers by food alone, then by food and catches, and compare the graphs.