← Cellular Automata From First Principles

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 — one historically important system, due to Bert Wang-Chak Chan (“Lenia: Biology of Artificial Life,” Complex Systems 28(3), 2019): not generic continuous CA, but Lenia specifically. The framing for this part so far:

Ch28–30
= components

Ch31
= one historically important system built from those components

Fidelity statement

The engine below implements the canonical Lenia update exactly — state plus timestep-scaled growth of the convolved field, clipped to [0, 1] — with the exponential growth mapping from Chan’s paper. It differs from full Lenia in scope, stated here so later comparisons use the same baseline:

canonical Lenia
  multi-shell kernels (β peak vectors)
  multiple kernels and channels
  exponential / polynomial / rectangular cores

this chapter
  single-ring exponential kernel (rank-1: one shell)
  single channel
  precomputed FFT convolution (periodic boundaries —
  the paper confirms FFT implies periodicity)

Similar ingredients are not the same model. Everything presented as “Lenia” here is single-kernel Lenia; multi-kernel and multi-channel generalizations arrive in Chapter 34, where the book says so.


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

This is Chapter 29’s ring kernel driven by the config object — same function, parameterized. Canonical Lenia generalizes this with β-controlled concentric shells; the multi-ring variant from Chapter 29 points in that direction.


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. (Centering uses the parenthesized form from Chapter 29’s bug fix.)


Growth function

Reuse Chapter 30’s gaussian_growth(u, mu, sigma) unchanged — the exponential growth mapping of the paper, verified with peak exactly +1 at mu. One owner per concept holds across the substrate boundary too; this chapter adds no new growth name.


The complete step

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

    delta = gaussian_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: Chapter 30’s lenia_like_step with the kernel transform precomputed. One owner per concept holds — this function owns the production step; the earlier one remains the teaching form, and the text says so instead of letting two names drift.

The full runtime, including the once-per-config precomputation that Chapter 30’s conceptual loop omits:

    flowchart LR
    C[config] --> K[build kernel once]
    K --> F[FFT once]
    F --> L[step loop]
    S[state] --> L
    L --> P[potential via FFT multiply]
    P --> G[growth response]
    G --> I[integrate + clip]
    I --> S
  

The engine is small: a dozen lines.

Canonical component, book form, and what is deferred — the fidelity table for this part so far:

ComponentCanonical LeniaThis bookDeferred to
update equationstate + dt·growth, clippedexact same form— (owned here)
growth mappingexponential (also polynomial)exponential onlyCh34+ (single form suffices)
kernelmulti-shell β vectorsingle Gaussian ring (rank-1)noted; multi-ring in Ch29 points there
channelsmulti-kernel/channel systemssingle channelCh34
boundariesFFT implies periodicsame, stated—

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

(Verified: 500 steps stay finite; mass ≈ 3395 with active fraction ≈ 0.21 — persistence, not explosion or collapse, on the reference configuration.)


Most seeds will not become organisms

This is important.

Possible outcomes include:

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

(“Organism” and “creature” from here on are the community’s descriptive terms — Chan’s paper borrows the biological vocabulary with explicit caveats, and so do we. A persistent moving structure is not life, not agency, and not self-reproduction: the paper itself notes self-replication is yet to be discovered in Lenia.)

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

Part III’s metrics plug directly into the artificial-life engine. The runner returns a metric log (renamed from the discrete stage’s trajectory-run to avoid the collision — this one logs measurements, not states):

def run_lenia(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

Mass and mean absolute change are Chapter 28’s continuous-state observables, Chapter 18’s ideas adapted rather than reused unchanged. A 500-step reference run shows what persistence looks like quantitatively — mass settling near 3400 with sustained per-step activity, neither collapse nor flood:

Mass and per-step activity over 500 steps: settling, not exploding or vanishing (reference config, 128x128)


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 — the book’s centroid, analogous to the paper’s mass-centroid tracking that grounds its locomotion measurements in numbers rather than screenshots.

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.


Research

  • Chan, B. W.-C. — Lenia: Biology of Artificial Life (Complex Systems 28(3), 2019). The primary source for this chapter and the ones that follow: the canonical update equation, kernel core/shell (β) construction, exponential growth mapping, FFT implementation with periodic boundaries, GoL as a special case, and the stated scope (single account here; multi-kernel/channel generalizations exist). Freely accessible preprint verifies every fidelity claim above. https://arxiv.org/html/1812.05433v3

  • Chan, B. W.-C. — Lenia project page (author-maintained). The living reference: species galleries, interactive demos, and pointers to Flow-Lenia and later work. Use it to check what named patterns (Orbium and kin) actually look like before invoking them. https://chakazul.github.io/lenia.html