Build Lenia From First Principles

Page content

Cellular Automata From First Principles 30: Build Lenia From First Principles

We now have every conceptual component needed for a minimal Lenia implementation:

continuous state
radial kernel
convolution
smooth growth function
small time step
bounded update

This chapter assembles them into one runnable system.


Represent the model parameters explicitly

from dataclasses import dataclass


@dataclass(frozen=True)
class LeniaConfig:
    radius: int = 13
    ring_center: float = 0.5
    ring_width: float = 0.15
    mu: float = 0.15
    sigma: float = 0.03
    dt: float = 0.1

Keeping parameters in one object makes experiments reproducible.


Build the kernel

import numpy as np


def build_kernel(config: LeniaConfig):
    r = config.radius
    y, x = np.mgrid[-r:r+1, -r:r+1]
    distance = np.sqrt(x*x + y*y) / r

    kernel = np.exp(
        -((distance - config.ring_center) ** 2)
        / (2 * config.ring_width ** 2)
    )

    kernel[distance > 1.0] = 0.0
    kernel[distance == 0.0] = 0.0
    kernel /= kernel.sum()
    return kernel

Precompute the frequency-domain kernel

def kernel_fft(kernel, shape):
    padded = np.zeros(shape, dtype=np.float64)
    kh, kw = kernel.shape
    padded[:kh, :kw] = kernel
    padded = np.roll(padded, -(kh // 2), axis=0)
    padded = np.roll(padded, -(kw // 2), axis=1)
    return np.fft.fft2(padded)

We only need to transform the kernel once while its parameters stay fixed.


Growth function

def growth(u, mu, sigma):
    return 2.0 * np.exp(
        -((u - mu) ** 2) / (2 * sigma ** 2)
    ) - 1.0

The complete step

def lenia_step(state, kernel_f, config):
    potential = np.fft.ifft2(
        np.fft.fft2(state) * kernel_f
    ).real

    delta = growth(potential, config.mu, config.sigma)

    next_state = state + config.dt * delta
    return np.clip(next_state, 0.0, 1.0)

That is the core engine.

The remarkable thing is how small it is.


Seed a localized pattern

def random_seed(shape=(128, 128), patch=24, seed=42):
    rng = np.random.default_rng(seed)
    state = np.zeros(shape, dtype=np.float64)

    y0 = shape[0] // 2 - patch // 2
    x0 = shape[1] // 2 - patch // 2
    state[y0:y0+patch, x0:x0+patch] = rng.random((patch, patch))

    return state

Run the model:

config = LeniaConfig()
kernel = build_kernel(config)
kernel_f = kernel_fft(kernel, (128, 128))
state = random_seed()

for _ in range(500):
    state = lenia_step(state, kernel_f, config)

Visualize:

import matplotlib.pyplot as plt

plt.imshow(state, cmap="viridis", vmin=0, vmax=1)
plt.axis("off")
plt.show()

Most seeds will not become organisms

This is important.

Possible outcomes include:

dies out
explodes
becomes uniform
oscillates chaotically
forms transient blobs
settles into localized structure

Interesting life-like patterns occupy restricted regions of both initial-condition space and parameter space.

That turns discovery into a search problem.


Instrument the run

def run(state, kernel_f, config, steps=500):
    history = []

    for step_index in range(steps):
        before = state
        state = lenia_step(state, kernel_f, config)

        history.append({
            "step": step_index,
            "mass": float(state.sum()),
            "mean": float(state.mean()),
            "activity": float(np.mean(np.abs(state - before))),
        })

    return state, history

The metrics from Part III now plug directly into the artificial-life engine.


Detect localization

One useful first test is whether most activity remains concentrated in a small region.

def active_fraction(state, threshold=0.05):
    return float(np.mean(state > threshold))

A world-filling soup might have:

active_fraction ≈ 1

while a localized creature-like pattern may occupy only a small fraction of the board.

No single threshold proves an organism exists, but it gives search infrastructure something to work with.


Detect motion with a center of mass

def center_of_mass(state):
    total = state.sum()
    if total <= 1e-12:
        return None

    y, x = np.indices(state.shape)
    return (
        float((y * state).sum() / total),
        float((x * state).sum() / total),
    )

Compare positions over time.

A persistent localized pattern whose center moves may be behaving like a mobile artificial organism.

Periodic boundaries make center-of-mass tracking near edges trickier, so production analysis should unwrap trajectories or keep organisms away from boundaries during evaluation.


Save a parameterized experiment

A Lenia result without its parameters is barely reproducible.

Store at least:

config
initial seed
board dimensions
step count
metric history
final state

For example:

np.savez_compressed(
    "lenia_run.npz",
    initial=initial_state,
    final=state,
    kernel=kernel,
)

Store the config separately as JSON or structured metadata.


From automaton to laboratory

Notice how far we have come from Rule 30.

The architecture is still recognizable:

state
local perception
transition rule
next state

But every component has become continuous and parameterized.

This is exactly why the first-principles route matters.

Lenia no longer looks like magic.

It looks like a sequence of understandable design choices.


The hard part starts now

We can run Lenia.

That is not the same as discovering interesting life.

The next challenge is experimental:

Which seeds survive?
Which parameter settings create localized structure?
Which patterns move?
Which regenerate?
Which are genuinely different rather than tiny variations?

In the next chapter we will turn Lenia into a discovery system and begin searching for persistent artificial organisms instead of manually guessing parameters forever.