Build a Reusable Cellular Automata Engine

Page content

Cellular Automata From First Principles 53: 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.


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.


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 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)

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(state):
    ...
    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

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.