Lenia, or a Convolutional Game of Life
What happens if we replace the binary cells of Conway's Game of Life with a smooth field and let every point interact through a convolution? We get something close to Lenia, the continuous cellular automaton introduced by Bert Wang-Chak Chan. In this post, we will build a small single-channel Lenia experiment using Wolfram Language. The whole update can be written in one line.
There are only two operations to understand: a convolution , which measures the local neighborhood, and a growth function , which decides whether that neighborhood should grow () or decay (). Applied repeatedly, they resemble a recurrent convolutional network, although nothing is trained here. The kernel and the growth curve are prescribed arbitrary "by hand".
We will start with the standard Lena test image converted to grayscale. It won't represent a "Lenia organism" rather a sample structured field with values in range . Later, we will replace it with an actual localized pattern.
canvas = ColorConvert[
ImageResize[ExampleData[{"TestImage", "Lena"}], {300,300}],
"Gray"] (*VB[*)(FrontEndRef["ec11b131-b328-4b93-9665-4d1ff7f48e5c"])(*,*)(*"1:eJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKpyYbGiYZGhvqJhkbWeiaJFka61qamZnqmqQYpqWZp5lYpJomAwB9wRV6"*)(*]VB*) The kernel
First we need a neighborhood. Instead of counting a few adjacent cells, Lenia uses a smooth radial kernel. And it is not a simple Gaussian, since it leads to rather boring results:
Here in Lenia a kernel is represented as a sum of such weighted and scaled by . It copies this bump into concentric rings:
The function ks[r, α, β] below samples this profile on a square grid with spacing , cuts away everything outside the unit disk, and normalizes the result. The normalization matters: all weights add up to one, so the next convolution behaves like a weighted local average.
This is a very complicated formulation, but, well, let us implement it in code anyway... A note from the author
For the first experiment we will use three rings with relative heights .
k[α_, x_] :=
If[0 < x < 1,
Exp[α (1 - 1/(4 x (1 - x)))],
0.0
];
ks[r_, α_, β_List] := Module[
{rings = Length[β], m},
m = Table[
With[{ρ = Norm[{i, j}]},
If[ρ >= 1,
0.0,
With[{q = rings ρ},
β[[Floor[q] + 1]]
k[α, FractionalPart[q]]
]
]
],
{i, -1.0, 1.0, 1.0/r},
{j, -1.0, 1.0, 1.0/r}
];
m/Total[m, 2]
] // Quiet;
ArrayPlot[ks[20, 4, {1.0, 2/3, 1/3}], Axes->True] (*VB[*)(FrontEndRef["858b8a7b-97a6-42e3-b684-46efdd8e055c"])(*,*)(*"1:eJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKW5haJFkkmifpWponmumaGKUa6yaZWZjompilpqWkWKQamJomAwCCJhW8"*)(*]VB*) These are rings we were talking about. It sort of generalizes a rectangular kernel used in a classical GOL.
Apply the kernel
Now we can turn the state field into a neighborhood potential. This basically means we create another field with the same size derived by the convolution with our kernel
In Wolfram Language this is just ImageConvolve or ListConvolve for plain lists. Working with images can be slightly faster in some cases and easier to display. Try to avoid loops, or any direct pixel mutations when working with large arrays of data.
Think like a shader!
We wrap it in U[r, α, β], so the same convolution operator can be applied again at every step.
There is one important boundary choice here. Padding -> "Periodic" joins opposite edges of the image, turning the finite grid into a torus. A pattern that leaves on the right re-enters on the left; there is no artificial wall at the image boundary.
Let's see what this weighted neighborhood looks like on our test image.
U[r_, \[Alpha]_, \[Beta]_] := With[{m=ks[r, \[Alpha], \[Beta]]}, ImageConvolve[#, m, Padding->"Periodic"]&]
U[20, 4, {1.0, 2/3, 1/3}][canvas] (*VB[*)(FrontEndRef["5cc2945c-9c08-4e02-87af-88e28b56dac1"])(*,*)(*"1:eJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKmyYnG1mamCbrWiYbWOiapBoY6VqYJ6bpWlikGlkkmZqlJCYbAgCB3RWv"*)(*]VB*) It is important to note, that here we defined U as a function generator. When U is called, it generates matrix ks and packs it into an anonymous function
U[20, 4, {1.0, 2/3, 1/3}] // Shallow Function[ImageConvolve[Slot[1], {{0., 0., 0., 0., 0., <<36, _Real>>}, {0., 0., 0., 0., 0., <<36, _Real>>}, {0., 0., 0., 0., 0., <<36, _Real>>}, {0., 0., 0., 0., 0., <<36, _Real>>}, {0., 0., 0., 0., 0., <<36, _Real>>}, <<36, {_Real..}>>}, Padding -> "Periodic"]] This allows to avoid kernel generation on every call.
The growth function
The potential U is not yet an update. Lenia passes it through a non-monotone growth curve:
A neighborhood close to the preferred value grows. Values farther away decay, while controls the width of the favorable band. This maps the potential to a signed rate between approximately and .
This is also where the neural-network analogy becomes less literal. Unlike a monotone activation such as ReLU, the Lenia response selects a narrow interval of neighborhood densities.
The preview below uses and . ImageAdjust changes only the displayed contrast only to see the effect better
G[\[Mu]_, \[Sigma]_][U_] := ImageApply[Function[a, 2 Exp[- (*FB[*)(((*SpB[*)Power[(a - \[Mu])(*|*),(*|*)2](*]SpB*))(*,*)/(*,*)(2 (*SpB[*)Power[\[Sigma](*|*),(*|*)2](*]SpB*)))(*]FB*)]-1.0], U]
G[2 0.16, 0.021][U[20, 4, {1.0, 2/3, 1/3}][canvas]] // ImageAdjust (*VB[*)(FrontEndRef["6a41d2f0-4c6f-4e1e-8e6f-5bb0edd25597"])(*,*)(*"1:eJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKmyWaGKYYpRnomiSbpemapBqm6lqkAlmmSUkGqSkpRqamluYAi3QWCA=="*)(*]VB*) Close the loop
Now we can put convolution and growth together:
The code below stores the convolution operator in potential, keeps the current image in field, and advances it with . Clip is part of the rule: it keeps every state inside .
There are two clocks in this cell. The model advances by per update, while Refresh[..., 1/60.0] asks the notebook to redraw at roughly 60 Hz.
With only these few lines, the image becomes a recurrent dynamical system.
potential = U[30, 4, {1.0, 2/3, 1/3}];
field = canvas;
Refresh[Colorize[
field = Clip[
field + 0.1 G[0.16, 0.011][potential[field]],
{0.,1.}]],
1/60.0] Record a trajectory
A live Refresh loop is useful while experimenting, but a fixed animation is easier to inspect and share. Here we compute 60 updates first and then pass the frames to AnimatedImage.
Since , the trajectory covers 6 model-time units. Playing 60 frames at 30 frames per second produces a two-second animation. These are separate quantities: changing FrameRate changes the movie, not the Lenia dynamics.
The assignment field = canvas makes the cell reproducible. Every evaluation starts from the same image.
potential = U[30, 4, {1.0, 2/3, 1/3}];
field = canvas;
AnimatedImage[Colorize/@Table[
field = Clip[
field + 0.1 G[0.16, 0.011][potential[field]],
{0.,1.}], {60}], FrameRate->30] %28%2AVB%5B%2A%29%28CoffeeLiqueur%60Extensions%60Video%60Internal%60imgSymbol%243445231%29%28%2A%2C%2A%29%28%2A%221%3AeJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKG6ZapCQaJafoJlumWeqaJJlY6iaZGZjqJqZZmpgYpySZJ6ZZAACSAxZB%22%2A%29%28%2A%5DVB%2A%29 From an image to a creature
So far, Lena was only a structured test field. Now let's use a localized Lenia pattern we stored in the notebook as "felicity-8c2". NotebookRead restores it directly into field.
Download this notebook to try it by yourself if you are reading it from a web page now
NotebookRead[NotebookStore["felicity-8c2"]] (*VB[*)(FrontEndRef["98a5f16b-9465-4959-a063-705d17eeb8b0"])(*,*)(*"1:eJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKW1okmqYZmiXpWpqYmeqaWJpa6iYamBnrmhuYphiap6YmWSQZAAB3XBUk"*)(*]VB*) This run uses
The initial pattern is only half of the experiment. Its parameter tuple is equally important: small changes in or can alter motion, stability, or survival completely.
There is one practical caveat. The store key is local to this notebook. To reproduce the result elsewhere, we would need to export the state array together with the kernel parameters, time step, grid size, and periodic boundary condition.
potential = U[20, 4, {1.0, 2/3, 1/3}];
field = NotebookRead[NotebookStore["felicity-8c2"]];
Refresh[Colorize[
field = Clip[
field + 0.1 G[0.13805, 0.01312][potential[field]],
{0.,1.}]],
1/60.0] Make the final animation
We can record the stored organism in exactly the same way. This time we generate 100 updates and play them at 25 frames per second
potential = U[20, 4, {1.0, 2/3, 1/3}];
field = NotebookRead[NotebookStore["felicity-8c2"]];
Magnify[AnimatedImage[Colorize/@Table[
field = Clip[
field + 0.1 G[0.13805, 0.01312][potential[field]],
{0.,1.}], {100}], FrameRate->25], 2] %28%2ABB%5B%2A%29%28%28%2AVB%5B%2A%29%28CoffeeLiqueur%60Extensions%60Video%60Internal%60imgSymbol%243465405%29%28%2A%2C%2A%29%28%2A%221%3AeJxTTMoPSmNkYGAoZgESHvk5KRCeEJBwK8rPK3HNS3GtSE0uLUlMykkNVgEKp5knmpmbmBjrmpqZW%2BqamBqm6FoYmJjpGiYlJpsmm6YYJ5kaAAB14RUW%22%2A%29%28%2A%5DVB%2A%29%29%28%2A%2C%2A%29%28%2A%221%3AeJxTTMoPSmNiYGAo5gMSwSWVOakuqcn5RYkl%2BUUQcRYgEVSak1rMC2T4JqbnZaZlJieWZObnQeRZgURIZm5qcSaYywkkPPMyUosyS1JT0EzgAVtRlFngn%2BeZV1BaAtbrlphTnAoAxW0hrw%3D%3D%22%2A%29%28%2A%5DBB%2A%29 Make this your own!
The most sensitive controls are and , so small parameter sweeps around the creature are a good next experiment. One can also replace the initial field, change the ring amplitudes , or track the mass and centroid over time.
See you next time!
References
- B. W.-C. Chan, Lenia—Biology of Artificial Life, Complex Systems 28(3), 251–286 (2019). Open-access preprint.
- B. W.-C. Chan, Lenia and Expanded Universe, Artificial Life Conference Proceedings 32, 221–229 (2020). Open-access preprint.
- S. Rafler, Generalization of Conway's Game of Life to a Continuous Domain—SmoothLife (2011).
- Lenia project page and reference implementations.