Build Your First Automaton
Cellular Automata From First Principles 01: Build Your First Automaton
In the previous chapter we reduced a cellular automaton to four things:
space
state
neighborhood
rule
Now we are going to build one.
Not a framework.
Not a library.
One update step.
That is enough to expose the whole mechanism.
Start with a one-dimensional world
Create a row of cells and turn on the centre cell:
import numpy as np
width = 41
state = np.zeros(width, dtype=np.uint8)
state[width // 2] = 1
print(state)
Conceptually:
....................#....................
We will use 0 for an empty cell and 1 for an active cell.
Define one local rule
Suppose a cell becomes active when exactly one of the three cells in its neighborhood is active.
That is not yet a named elementary rule. It is simply a transition we can understand immediately.
def local_rule(left, centre, right):
return int(left + centre + right == 1)
Now apply it across the row:
def step(state):
next_state = np.zeros_like(state)
for i in range(len(state)):
left = state[(i - 1) % len(state)]
centre = state[i]
right = state[(i + 1) % len(state)]
next_state[i] = local_rule(left, centre, right)
return next_state
The modulo operator gives us periodic boundaries:
left edge <----------------> right edge
The world wraps around like a ring.
Run several generations
def run(initial_state, generations):
history = [initial_state.copy()]
state = initial_state.copy()
for _ in range(generations - 1):
state = step(state)
history.append(state.copy())
return np.array(history)
Now:
history = run(state, generations=25)
The result is a two-dimensional array:
rows = time
columns = space
This is one of the useful tricks of 1D cellular automata.
A one-dimensional system evolving through time naturally becomes a two-dimensional image.
Visualize the history
import matplotlib.pyplot as plt
plt.figure(figsize=(10, 6))
plt.imshow(history, cmap="binary", interpolation="nearest")
plt.xlabel("cell")
plt.ylabel("generation")
plt.show()
You have now built a cellular automaton.
The entire runtime is:
for every generation:
for every cell:
read neighborhood
apply rule
write next state
Why we need two states of the world
A common implementation mistake is to update the current row in place.
For example:
for i in range(len(state)):
state[i] = local_rule(...)
That changes the meaning of the simulation.
Cells later in the loop would observe already-updated neighbors while earlier cells observed old neighbors.
Instead we need:
current generation
|
v
compute every update
|
v
next generation
Only after the whole generation has been calculated do we replace the current state.
This is synchronous updating.
Later we will deliberately experiment with asynchronous updates, but they should be a model choice rather than an accidental bug.
Boundary conditions are part of the model
Our modulo indexing created periodic boundaries.
We could instead use fixed zeros:
def get_cell(state, i):
if i < 0 or i >= len(state):
return 0
return state[i]
Or reflective boundaries.
Or an effectively infinite sparse world.
These choices matter.
The update rule is not the entire model. The geometry and boundary behavior also determine what patterns are possible.
Separate mechanism from rule
We can make the engine accept any rule function:
def step(state, rule):
next_state = np.zeros_like(state)
for i in range(len(state)):
neighborhood = (
state[(i - 1) % len(state)],
state[i],
state[(i + 1) % len(state)],
)
next_state[i] = rule(*neighborhood)
return next_state
Now the architecture is cleaner:
simulation engine
|
+--> neighborhood lookup
+--> time stepping
+--> boundary behavior
rule
|
+--> local transition only
That separation will become useful as soon as we want to explore hundreds of rules.
A compact text renderer
Before reaching for plots, it is useful to have a tiny text view:
def render_row(state):
return "".join("#" if cell else "." for cell in state)
state = np.zeros(41, dtype=np.uint8)
state[len(state) // 2] = 1
for _ in range(20):
print(render_row(state))
state = step(state, local_rule)
Text output is excellent for debugging because it removes the visualization stack from the problem.
If a rule behaves unexpectedly, we can inspect the exact cells.
What we have built
Our automaton now has a reusable execution loop:
current = initial
for generation in range(n):
observe(current)
current = step(current, rule)
That shape will survive almost the entire book.
Even when the state becomes a tensor with many channels and the rule becomes a neural network, the conceptual loop remains:
observe local state
apply shared transition
advance time
In the next chapter we will replace our hand-written rule with a more powerful idea: encode the complete rule table as a single integer.
That gives us all 256 elementary cellular automata for free.