Growth Functions
Cellular Automata From First Principles 29: Growth Functions
We now have two pieces:
continuous state
↓
weighted neighborhood
But a neighborhood value does not tell us what should happen next.
We need a response curve.
In Lenia-style systems that curve is usually called a growth function.
It converts neighborhood potential into local growth or decay.
A Gaussian-shaped preference
A useful starting point is:
import numpy as np
def gaussian_growth(u, mu=0.15, sigma=0.03):
return 2.0 * np.exp(
-((u - mu) ** 2) / (2 * sigma ** 2)
) - 1.0
The output is approximately:
-1 strong decay
0 neutral
+1 strong growth
Plot it:
import matplotlib.pyplot as plt
u = np.linspace(0.0, 0.4, 500)
g = gaussian_growth(u)
plt.plot(u, g)
plt.axhline(0.0, linewidth=1)
plt.xlabel("neighborhood potential")
plt.ylabel("growth")
plt.show()
The rule prefers a particular local density around mu.
Too little activity decays.
Too much activity also decays.
Only a band around the preferred neighborhood produces positive growth.
Why this can create boundaries
Imagine a blob of active cells.
Inside the blob:
neighborhood potential may be too high
→ decay
Far outside:
neighborhood potential is too low
→ decay
Near a certain boundary region:
neighborhood potential is just right
→ growth
That creates a feedback mechanism capable of maintaining spatial structure.
The rule does not explicitly say:
Make an organism-shaped boundary.
It only rewards a certain local field value.
Growth is not next state
This distinction matters:
growth = gaussian_growth(neighborhood)
is not the next state.
We integrate it:
def integrate(state, growth, dt=0.1):
return np.clip(state + dt * growth, 0.0, 1.0)
So the full step becomes:
def lenia_like_step(state, kernel, dt=0.1, mu=0.15, sigma=0.03):
neighborhood = fft_convolve(state, kernel)
growth = gaussian_growth(neighborhood, mu=mu, sigma=sigma)
return np.clip(state + dt * growth, 0.0, 1.0)
Parameters now have clear meanings
We can interpret each parameter:
mu preferred neighborhood density
sigma tolerance around that preference
dt speed of change
kernel spatial scale of perception
This is much better than hiding everything inside a single opaque update function.
Narrow versus broad growth
Try:
for sigma in [0.01, 0.03, 0.08]:
plt.plot(u, gaussian_growth(u, mu=0.15, sigma=sigma), label=str(sigma))
plt.legend(title="sigma")
plt.show()
A narrow function is selective.
A broad function tolerates many neighborhood values.
That can radically change the stability of patterns.
Growth fields are worth visualizing
During debugging, show all three layers:
state
neighborhood
local growth
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
axes[0].imshow(state, cmap="viridis")
axes[1].imshow(neighborhood, cmap="magma")
axes[2].imshow(growth, cmap="coolwarm", vmin=-1, vmax=1)
plt.show()
A pattern that mysteriously dies becomes much easier to explain when you can see that almost every cell is receiving negative growth.
Growth and decay balance
A localized pattern survives only if growth and decay balance over time.
Track total mass:
masses = []
for _ in range(500):
masses.append(state.sum())
state = lenia_like_step(state, kernel)
Possible outcomes:
mass -> 0
pattern dies
mass -> grid maximum
pattern floods world
mass oscillates in bounded range
persistent dynamics
Persistence does not require exact mass conservation.
It requires long-term balance between local creation and removal.
Later Flow-Lenia will deliberately change this assumption.
Growth curves can be different shapes
A Gaussian is convenient, not mandatory.
For example:
def triangular_growth(u, center=0.15, width=0.05):
score = 1.0 - np.abs(u - center) / width
return np.clip(2 * score - 1, -1, 1)
Or a smooth polynomial bump.
Different response families create different dynamical systems.
Inspect local causality
Pick one cell:
y, x = 64, 64
print("state:", state[y, x])
print("neighborhood:", neighborhood[y, x])
print("growth:", growth[y, x])
This gives a local causal chain:
surrounding state
↓
weighted perception
↓
growth response
↓
state increment
Even when the global pattern becomes astonishingly complex, the local mechanism remains inspectable.
We now have almost all of Lenia’s skeleton
At the highest level:
A(t)
↓ convolution K
U(t)
↓ growth G
G(U)
↓ small integration step
A(t + dt)
The next chapter will put those components together into a reusable implementation.
We will not begin by copying a mysterious Lenia codebase.
We will build Lenia from first principles, component by component, using the machinery we already understand.