A simulation of organisms navigating their environment according to inherited traits and decision trees. Rendered with ebitengine
Protozoa randomly generates a number of organisms and food items on a 2D grid. Per render cycle, each organism chooses a simple action (eat, move, turn, attack etc.) based on a randomly-generated decision tree with which it was initialized. Organisms that survive long enough can spawn offspring with very slight mutations, thus propagating successful traits and behaviors.
The environment consists of a 2D wraparound grid. Each location contains a ph value (0-10). These ph values play a large role in organism health, and are likewise affected by certain organism actions (ie. growth).
Each cycle, ph values diffuse between neighboring grid locations at a regular rate, such that the whole environment will gradually approach a single ph value in the absence of organism activity.
Low ph (acidic) locations appear green, high ph (alkaline) locations are pink, and neutral locations (~5.0 ph) are black.
Additionally, the environment can be separated by walls into 'pools' with small openings allowing diffusion and movement in between. This is meant to allow different families of organisms to develop in isolation longer than would otherwise be possible. (The existence and size of these pools can be set in the configuration json files in settings/)
'Food' items are generated when organisms die. Each food item is represented by a dark gray square and contains a value between 0 and 100, representing how much the food item contains. When an organism sees a food item directly ahead, it can choose to 'eat' it, subtracting some value from the food and adding it to its own health. If a food item's value is reduced to 0, it disappears from the grid. Conversely, when an organism's health is reduced to 0 it 'dies' and is immediately replaced with a food item, whose value is set equal to the organism's size at death.
Apart from feeding organisms, food items also prevent movement. Organisms and food items cannot occupy the same location, and an organism facing a food item directly ahead cannot move through it.
Organisms are represented by colored squares of different sizes, and they perform actions in their environment according to a set of genetic traits and a single decision tree. 'Health' and 'energy' are the same thing for organisms, and an organism's actions (moving, eating, etc.) may reduce its own health by some small amount to represent the energy exertion needed to do them. Further, an organism unable to tolerate the ph of its location will also have its health reduced until conditions improve.
An organism's health is limited by its current size, so an organism of size 50 will have a max health of 50. When an organism gains more health than its size allows, it 'grows' in size by some fraction of the excess health gain.
Initial organisms are generated with random values for several 'genetic' traits that define its size limitations, its ph tolerance, the time it waits betweeen spawning, etc. When spawning a new organism, the traits of the parent are adjusted by small random amounts and passed down to the new child.
- Color - generated from random hue, saturation, and brightness
- MaxSize - the maximum size an organism can grow
- SpawnHealth - the initial health given to a spawned child, which is also subtracted from the parent's health
- MinHealthToSpawn - the minimum health required by the parent to spawn a new child (never less than SpawnHealth)
- MinCyclesBetweenSpawns - the minimum number of cycles that must pass before the organism can produce another child
- IdealPh - The middle of the organism's ph tolerance range
- PhTolerance - The absolute ph distance the organism can go from its ideal ph without adverse effects. (eg. An ideal ph of 3 and ph tolerance of 1 provide a tolerance zone of 2-4 ph)
- PhEffect - the positive or negative factor the organism's growth has on the ph level of its location)
Each organism's behavior is governed by a decision tree composed of various conditions and actions. Organisms generated at simulation start are given randomly-selected trees built from these decision nodes, while spawned children inherit an identical or similar variation of their parents' decision tree. and chosen from the following:
- CanMoveAhead - checks if the organism can move forward (false if a food item or another organism directly ahead)
- IsRandomFiftyPercent - returns true if a randomly generated float is less than .5
- IsFoodAhead - true if a food item directly ahead
- IsFoodLeft - true if a food item lies 90 degrees to the left
- IsFoodRight - true if a food item lies 90 degrees to the right
- IsOrganismAhead - true if an organism directly ahead
- IsOrganismLeft - true if an organism lies 90 degrees to the left
- IsOrganismRight - true if an organism lies 90 degrees to the right
- IsBiggerOrganismAhead - true if an organism of greater size directly ahead
- IfHealthAboveFiftyPercent - true if organism's health values more than half its current size
- IsHealthyPhHere - true if the ph level at current location is within the organism's tolerance - having no harmful health effects and allowing for chemosynthesis
- IsHealthierPhAhead - true if the ph level directly ahead is closer to the organism's ideal ph than the ph at its current location tolerance
- Chemosynthesis - generates a small amount of health, if performed at a location with healthy ph
- Eat - consumes a small amount of health to consume any food that lies directly ahead
- Move - consumes a small amount of health to move forward, if no food or organism directly ahead
- TurnLeft -- consumes a small amount of health to turn 90 degrees left
- TurnRight - consumes a small amount of health to turn 90 degrees right
- Attack - consumes a large amount of health to reduce the health of any organism directly ahead
- Feed - transfers a small amount of health to any organism directly ahead
Because decision trees are randomly generated and mutated, many trees will have areas of redundancy and illogic, containing branches that have no possibility of ever being reached. As a way to reward logical algorithms, Organisms lose a very small amount of health each cycle for every node in their decision tree, as a way to simulate the energy needed to process complicated decision-making. Thus, over time, subsequent mutations to decision trees should allow more efficient organisms to outpace those with similar behaviors but less efficient algorithms.
Clicking on an organism in the simulation grid will display its traits and decision tree in the left-hand panel, as shown:
As printed, each conditional statement (eg. "If Can Move Ahead") is followed by a line that splits into two branches. The first, top-most branch is the logic the organism will follow if the checked condition returns true. The second, bottom branch will evaluate if the condition returns false. All decision tree nodes evaluated in the previous cycle are followed by "◀◀". Thus, the example decision tree shows - in the previous cycle - the selected organism checked 'If Can Move Ahead' (true), checked 'If Food Right' (false), and so it chose the 'Move Ahead' action.
Protozoa runs in three distinct modes that share a common simulation core: a headless simulation that advances the world deterministically and writes a .pzr replay file, a replay viewer that re-runs the simulation against that file, and the renderer that paints whatever the live or replayed simulation currently looks like.
The headless loop is the source of truth. Given a seed and a config, it produces a fully deterministic sequence of cycles and streams the result to a .pzr file. The replay viewer just re-plays this sequence later.
flowchart TD
A[Config / settings.json] --> B[NewSimulation seed + options]
B --> C[Init managers:<br/>EnvironmentManager · FoodManager · OrganismManager]
C --> D[Open .pzr Writer]
D --> Loop{IsDone?}
Loop -- no --> Tick[cycle++]
Tick --> Env[Diffuse pH]
Env --> Food[Update food]
Food --> Decide[Organisms decide<br/>parallel: walk decision trees · build request map]
Decide --> Resolve[Organisms resolve<br/>sequential: apply health · spawn · removeIfDead]
Resolve --> History[Record per-ancestor population<br/>+ pH-effect / pH-distribution buckets]
History --> Snap{cycle %<br/>CheckpointInterval == 0?}
Snap -- yes --> SW[WriteSnapshot:<br/>RNG · organisms · grid · pH]
Snap -- no --> Loop
SW --> Loop
Loop -- yes --> Close[CloseRecorder<br/>flush DescendantTrees + History sections]
Close --> Done([Done])
The decide phase is parallel because each organism reads its own state and writes only to its worker's local request map; the resolve phase is sequential and the only place that mutates shared grids and applies organism deaths.
The replay viewer opens a .pzr file, restores from the nearest snapshot, then advances the simulation forward in real time. Every cycle the viewer plays is the same simulation step the headless loop ran — same RNG, same organism actions, same outcomes.
flowchart TD
F[.pzr file] --> R[checkpoint.Reader]
R --> S0[Read first snapshot]
R -. scan end-of-file .-> ES[DescendantTrees + History payloads]
S0 --> RS[RestoreFromSnapshot:<br/>organisms · grid · RNG · pH]
ES --> Inject[RestoreDescendantTrees<br/>RestoreHistory]
RS --> Inject
Inject --> Live[(Live Simulation<br/>exposed to UI)]
Live --> Tick
subgraph Tick [Each ebiten tick]
direction TB
T1{AnimState.ShouldAdvance?}
T1 -- yes --> T2[BeforeUpdate:<br/>snapshot organism positions]
T2 --> T3[sim.Update<br/>re-runs the cycle deterministically]
T3 --> T4[AfterUpdate:<br/>build Frame map<br/>FromLocation → ToLocation]
T4 --> T1
T1 -- no --> T5[Hand state to renderer]
end
Live -. user scrubs .-> Seek
subgraph Seek [Scrubber click]
direction TB
K1[SeekToCycle target]
K1 --> K2[Find nearest snapshot ≤ target]
K2 --> K3[ResetFromSnapshot]
K3 --> K4[Re-inject trees + history]
K4 --> K5[Step forward to target cycle]
end
Seek -. resume play .-> Live
AnimState is the wall-clock pacer: at Speed 1x one cycle takes 4 animation frames at 12 fps; at higher speeds the cycle window shrinks (multiple sim steps may fire per tick); at fractional speeds (1/2x, 1/4x) the window stretches for slow-motion.
The renderer is read-only: it samples the live simulation and the current AnimState.Frames map and paints them. It never mutates simulation state.
flowchart TD
SimSt[Live Simulation state] --> Layers
AnimSt[AnimState.Frames<br/>id → from/to/direction/action] --> Org
subgraph Layers [Grid layers]
direction TB
Env[env layer<br/>sprite-native res<br/>CPU-bilinear pH<br/>wrap-aware]
Walls[walls layer]
FoodL[food layer<br/>sprite per size tier]
Org[organisms layer<br/>sprite per action,<br/>rotated by Direction,<br/>frame from Progress]
end
Env --> Compose
Walls --> Compose
FoodL --> Compose
Org --> Compose
Compose[drawLayer:<br/>wrap-aware tiling<br/>FilterNearest only]
Compose --> Sel[Overlay selection boxes]
Sel --> UI
Mini[Minimap<br/>async snapshot of full world] --> UI
Panel[Panel:<br/>stats · decision tree · graphs · replay controls] --> UI
UI[Interface.Render] --> Screen([screen])
The env layer is the only one with non-trivial colour math: each pixel is bilinearly interpolated from the four nearest cells' pH values with wrap-aware sampling, so the pH gradient stays smooth across the world-wrap boundary without the seam that GPU FilterLinear would leave there. Every other layer is sprite-native pixel art and composed with FilterNearest so the look stays crisp.
go get
go run .
-config Use overriden simulation constants. Ex:
go run . -config=settings/small.json
go run . -config=settings/big.json
-seed Set the random seed used by the simulation. Ex:
go run . -seed=2
-debug Display memory usage and FPS
go run . -debug=true
You can create your own .json config files to override simulation constants at runtime. To print the default settings as json (you can paste and edit this in a new configuration .json file)
go run . -dump-config
- Single trial:
go run . -headless
- Multiple trials:
go run . -headless -trials=10
Every run writes a .pzr replay file. By default it goes to a stable path in the system temp directory (protozoa_last.pzr) and gets overwritten the next time you start a new run. To keep a simulation around, save it to an explicit path instead:
- Save a run to a chosen file:
go run . -checkpoint=runs/big_mutation.pzr
- Replay a saved file:
go run . -replay=runs/big_mutation.pzr
- Resume the last run still sitting in the temp directory (no need to remember the path — useful right after closing the window):
go run . -resume=true
If the temp file has been cleared or a new run has already overwritten it, -resume falls through to the normal startup path and prints a note. Explicit -replay wins over -resume, and -resume is ignored under -headless (no GUI to show the replay).
Protozoa builds to WebAssembly via ebiten's wasm support. Sprite sheets, fonts, and settings/default.json are bundled into the binary via go:embed, so the wasm build is fully self-contained — no separate asset hosting needed.
Build the wasm binary and stage Go's wasm runtime under web/:
./web/build.sh
The script writes web/protozoa.wasm and web/wasm_exec.js. Then serve the web/ directory with any static file server and open it in a browser:
python3 -m http.server 8080 --directory web
# open http://localhost:8080/
Notes / current limitations:
- The wasm build runs in live-only mode: the in-browser file system is virtual, so saved replays don't survive a page reload. Replay controls work within a session but the
-resumeand-replayworkflows are desktop-only. - CLI flags don't apply (the browser doesn't pass
os.Args); the wasm build always boots with default settings. - The wasm binary is ~14 MB (mostly Go runtime + embedded sprite sheets). First load is the cold cost; subsequent loads can be cached.
go test test/utils_test.go





