← Cellular Automata From First Principles

From Discrete Cells to Continuous State

So far most of our cellular automata have used a small set of states:

0 or 1
empty / prey / predator
ready / firing / refractory

That makes rules easy to inspect.

But it also forces every update to make a hard categorical decision.

What happens if a cell can instead hold any value between 0 and 1?

0.0 -------------------------- 1.0

Now a cell can represent intensity, density, concentration, activation or some abstract amount of local material.

This single change opens the door to continuous cellular automata and, eventually, Lenia.

A definitional note first, because everything in this part depends on it: this extension keeps discrete space and discrete time but allows continuous state values. That steps outside the strict finite-state definition of a cellular automaton — the literature treats such systems as continuous-valued extensions rather than classical CA. Time stays stepped; only the state becomes smooth:

continuous state
≠ continuous time

Exactly one axis changes; the other two stay put:

AxisDiscrete CA (Ch 1–27)Continuous-state CA (here)
spacediscrete latticediscrete lattice (unchanged)
timestepped generationsstepped updates with dt (still stepped)
statefinite symbolsreal values in [0, 1] (the one change)
rule outputnext symbolgrowth rate, integrated in small steps

A continuous state grid

import numpy as np
import matplotlib.pyplot as plt

rng = np.random.default_rng(42)
state = rng.random((128, 128))

Every cell now contains a floating-point value:

print(state.min(), state.max())

The state space is no longer:

{0, 1}

but approximately:

[0, 1]

That means the transition rule can change a cell by a small amount rather than replacing one symbol with another.


Replace decisions with growth

A binary cellular automaton often computes:

neighborhood -> next state

A continuous automaton can instead compute:

neighborhood -> growth rate

Then:

next state = current state + small growth

In code:

def update(state, growth, dt=0.1):
    return np.clip(state + dt * growth, 0.0, 1.0)

The dt parameter matters.

It controls how much simulated time passes during one numerical step.


A tiny continuous automaton

Let’s use the local mean as our neighborhood signal (the same 3×3 operator as terrain smoothing):

def local_mean(state):
    total = np.zeros_like(state)

    for dy in (-1, 0, 1):
        for dx in (-1, 0, 1):
            total += np.roll(np.roll(state, dy, axis=0), dx, axis=1)

    return total / 9.0

Now define a growth rule that prefers neighborhood values near 0.5:

def growth_function(u, target=0.5, width=0.15):
    return 2.0 * np.exp(-((u - target) ** 2) / (2 * width ** 2)) - 1.0

(Later chapters will call this Gaussian-bump shape gaussian_growth with mu/sigma parameters — same curve, generalized naming. Verified range: exactly +1 at the target, approaching −1 far away.)

And one step (named distinctly from Part I’s discrete step, which takes a rule number — this one integrates a growth field):

def continuous_step(state, dt=0.1):
    neighborhood = local_mean(state)
    growth = growth_function(neighborhood)
    return np.clip(state + dt * growth, 0.0, 1.0)

Run it:

state = rng.random((128, 128)) * 0.3

for _ in range(200):
    state = continuous_step(state)

(Verified: values stay in [0, 1].)

This is deliberately crude — and honesty requires reporting what it actually does: from this start the field decays toward zero, variance collapsing to nothing. A 3×3 mean cannot sustain structure; it homogenizes. That failure is the chapter’s real result, because it tells us exactly what must get richer: the neighborhood. But structurally the demo already contains the central ingredients we need later:

continuous cell state
      ↓
continuous neighborhood signal
      ↓
continuous growth response
      ↓
small time update

Why np.clip appears everywhere

Without a constraint, repeated growth could push values below zero or above one.

If the model defines state as bounded density, we enforce that explicitly:

state = np.clip(state, 0.0, 1.0)

This is not merely a numerical trick.

It is part of the model semantics.

A bounded activation field is different from an unbounded concentration field.


Discrete time versus smooth time

Conway’s Life jumps:

generation 0
    ↓
generation 1
    ↓
generation 2

With a small dt, our new system changes more gradually:

t
↓
t + 0.1
↓
t + 0.2

We are still simulating in discrete computer steps.

But the rule is designed so those steps approximate smoother temporal evolution.

That distinction will matter when we tune the system.

A rule that behaves well with:

dt = 0.1

may become unstable with:

dt = 1.0

(The clip keeps values finite either way — boundedness is not stability. A clipped explosion is still an explosion of information about a bad timestep.)


Measure continuous state differently

Population count no longer makes much sense.

Instead measure total mass:

def mass(state):
    return float(state.sum())

Mean activation:

def mean_state(state):
    return float(state.mean())

Variance:

def state_variance(state):
    return float(state.var())

And change per step:

def mean_change(before, after):
    return float(np.mean(np.abs(after - before)))

Note this is a magnitude-of-change metric, distinct from Chapter 18’s fraction-changed change_rate (and from the mean_change record key built from it). Continuous state needs its own observable, not a relabeled binary one.

Part III becomes immediately useful again.

We already know how to treat dynamics as data.


Seed localized structure

Artificial-life systems are often easier to inspect when the initial state is localized:

state = np.zeros((128, 128), dtype=np.float64)
state[48:80, 48:80] = rng.random((32, 32))

Now we can ask:

does the pattern vanish?
does it explode?
does it stabilize?
does it move?
does it fragment?

These are the behavioral questions Part III prepared us to ask. No organism language yet — persistence, in this chapter, means a pattern that neither vanishes nor floods, nothing more.


The important abstraction

The most useful way to think about this chapter is not:

We replaced integers with floats.

The deeper shift is:

old CA
local configuration -> categorical replacement

continuous CA
local field -> rate of change

That gives us much finer control over how local influence accumulates.


But our neighborhood is still primitive

A 3x3 mean treats all nearby cells almost identically.

Artificial-life systems often need richer spatial structure.

We may want:

close cells     -> strong influence
middle ring     -> strongest influence
far cells       -> weak influence
outside radius  -> no influence

Writing that manually as dozens or hundreds of coordinate checks would be ugly and slow.

Fortunately, we already have the right mathematical tool.

In the next chapter we will replace hand-coded neighborhood loops with convolution kernels and turn the neighborhood itself into a configurable spatial function.


Research

  • Zenil, H. & Martinez, G. J. — Cellular Automata (Scholarpedia). The definitional license for this chapter: extensions that retain discrete space and time but allow continuous state values exist (fuzzy automata and kin) and lie outside the strict finite-state definition. Read before calling anything here “classical.” http://www.scholarpedia.org/article/Cellular_automata

  • Berto, F. & Tagliabue, J. — Cellular Automata (Stanford Encyclopedia of Philosophy). Notes the same boundary from the other side: even the discreteness constraint can be relaxed to real-valued states, while the locality and parallelism that make the systems cellular remain. The invariant Part IV protects. https://plato.stanford.edu/entries/cellular-automata/