← Cellular Automata From First Principles

Build a Reusable Cellular Automata Engine

Across this book we repeatedly rebuilt the same pieces:

state
neighborhood
rule
step loop
measurements
rendering

That repetition was useful while learning.

Now it is time to turn those concepts into interfaces.


Keep the engine small

A useful engine does not need to know what Conway’s Life, Lenia or an NCA is.

It only needs to orchestrate state transitions.

from dataclasses import dataclass
from typing import Callable, Any


@dataclass
class Automaton:
    state: Any
    step_fn: Callable[[Any], Any]

    def step(self):
        self.state = self.step_fn(self.state)
        return self.state

    def run(self, steps):
        for _ in range(steps):
            self.step()
        return self.state

That is intentionally boring.

Boring infrastructure is good infrastructure.

(Verified: the same class drives a NumPy Life step and a counting stub with zero changes — substitution is demonstrated below, not asserted.)


Separate rule from execution

A Conway rule can be one function.

A Lenia rule can be another.

A neural CA can be an object with learned parameters.

The engine should not care.

engine
  ↓
step(state)

That boundary gives us freedom to change implementations without rewriting the experiment layer.

life = Automaton(state=grid, step_fn=life_step_fn)
lenia = Automaton(state=field, step_fn=lenia_step_fn)

for system in (life, lenia):
    system.run(200)

Here life_step_fn is Chapter 51’s life_step, and lenia_step_fn is Chapter 31’s lenia_step with its kernel transform and config bound (for example with functools.partial), so that each takes the state as its only argument.


Make neighborhoods explicit

For more reusable systems we can separate neighborhood perception from transition logic:

@dataclass
class Rule:
    perceive: Callable
    transition: Callable

    def __call__(self, state):
        local = self.perceive(state)
        return self.transition(state, local)

Now different systems can share the same neighborhood machinery.


Add hooks instead of hard-coding features

@dataclass
class Observer:
    calls: list = None

    def __post_init__(self):
        self.calls = []

    def __call__(self, t, state):
        self.calls.append((t, int(state.sum())))


@dataclass
class Runner:
    automaton: Automaton
    observers: list

    def run(self, steps):
        for t in range(steps):
            state = self.automaton.step()
            for observer in self.observers:
                observer(t, state)

(Verified: observers fire once per step with the post-step state; measurement, frame-saving, and checkpointing all fit this signature.)

Observers might:

measure density
save frames
record hashes
collect loss
track centroid
write checkpoints

The simulation does not need to know which are enabled.


Keep rendering outside the state transition

Do not write:

def step_with_plots(state):
    ...compute...
    save_png(state)
    return next_state

That couples scientific computation to presentation.

Instead:

simulation produces state
observer records state
renderer turns records into artifacts

Now we can benchmark simulation without accidentally benchmarking image encoding.


State types can differ

Classical CA might use:

uint8 NumPy array

Lenia might use:

float32 NumPy array

NCA might use:

PyTorch tensor with batch and channel axes

A reusable engine should avoid forcing all systems into one representation unless there is a real reason.


Define invariants per model

Generic infrastructure does not eliminate model-specific tests.

Examples:

Rule 184 conserves car count
Life state remains binary
Lenia state remains in [0, 1]
Flow-style model conserves mass approximately
NCA tensor shape remains stable

The engine executes transitions.

The model contract defines what valid transitions mean.


A useful architecture

Experiment
   ↓
Runner
   ↓
Automaton
   ↓
Rule / Model
   ↓
Neighborhood backend

Alongside it:

Observers
Metrics
Renderers
Artifact store

As a dependency diagram — execution flows down, observation stays out of the transition path:

    flowchart TD
    E[Experiment] --> R[Runner]
    R --> A[Automaton]
    A --> M[Rule / Model]
    M --> N[Neighborhood backend]
    R --> O[Observers]
    O --> AR[Artifacts]
  

Component boundaries, stated as what each side must never own:

ComponentOwnsMust never own
Automatonstate + steppingmeasurement, rendering
Rule / Modeltransition logicexperiment config, I/O
Runnerloop + observer dispatchrule internals
Observersrecording, metricsstate mutation
Renderersfigures from recordssimulation decisions

This is enough structure to support everything we have built without turning a teaching project into a giant framework.


The goal is substitution

We should be able to change:

NumPy → PyTorch
rolls → convolution
convolution → FFT
hand rule → neural rule

without changing the surrounding experiment definition.

Once execution is separated this way, reproducibility becomes much easier to formalize.

That is the next chapter.


Research

  • Python documentation: dataclasses — Data Classes. The exact mechanics this chapter relies on: generated initializers, frozen=True for configuration objects, and field defaults — the boring, documented machinery that keeps the engine small instead of clever. https://docs.python.org/3/library/dataclasses.html

  • Mordvintsev et al. — self-organizing-systems research code. The reference implementation behind the Growing CA work: training loops, sample pools and damage regimes. Compare its structure with this chapter’s engine before copying either. https://github.com/google-research/self-organising-systems