# Build Lenia From First Principles # Book: Cellular Automata From First Principles # Chapter URL: https://programmer.ie/books/cellular-automata-from-first-principles/31-chapter/ # Prose fingerprint (sha256, normalized): 42ae766d0ea067cf07f5d6498aad70b8c5dd3135876d1ec8f84cca195088f7e9 # Snapshot generated at build time by Hugo. This is the complete rendered prose # of the chapter; it is not a transcript of the source file. 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: Component Canonical Lenia This book Deferred to update equation state + dt·growth, clipped exact same form — (owned here) growth mapping exponential (also polynomial) exponential only Ch34+ (single form suffices) kernel multi-shell β vector single Gaussian ring (rank-1) noted; multi-ring in Ch29 points there channels multi-kernel/channel systems single channel Ch34 boundaries FFT implies periodic same, 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: 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