Skip to content

Getting started

A vivarium is a set of elements that interact with each other in a grid, changing into other elements over time. With this TypeScript library you can define your vivarium and see it evolve on your screen. A tool for creative coders and learners, based on cellular automata theory.

Install Vivarium as a dependency. No other dependencies required.

npm i @wonderyard/vivarium

You can start defining your vivarium like this:

import { vivarium } from "@wonderyard/vivarium";
const vi = vivarium();

By default, the grid is non-wrapping — cells at the edges of the grid border with an imaginary wall of empty cells. If you want a toroidal grid where edges connect to the opposite side, pass { wrapping: true } as the second argument:

const vi = vivarium("square", { wrapping: true });

An element is the basic bit of your simulation. It can be represented with a little square on your screen.

Here’s how to define one.

const alien = vi.element("alien", "green"); // or a hex color like "#34d399"

We’re not drawing it on the screen yet. For now we are just defining what an alien is, so that our system is aware of its existence in the vivarium.

The second base concept of Vivarium is kinds. A kind is not an element, it doesn’t have a color and so it cannot be directly represented on a screen. However it can be inherited by elements to share common characteristics. It is not necessary to use kinds. Consider it an advanced concept that allows you to write less code and achieve more complex. To start, I’ll show you how to define one, which is similar to how we define elements.

const sentient = vi.kind("sentient");

The heart of Vivarium is made of rules. A rule is a way to tell elements how to behave in our vivarium. Every step of the simulation, an element can become another element or stay the same. Let’s see a simple rule first:

const space = vi.element("space", "blue");
const alien = vi.element("alien", "green");
space.to(alien); // all spaces become aliens

The method to can be read as “becomes” or “evolves to”. So with this last line, what we are saying is: when we will run the system, at every simulation step, every space element will become an alien element. It’s like aliens appearing out of nowhere, so on the screen we would see every blue cell in the grid becoming green as soon as we start the simulation. A room filled with aliens! (You might need to put your imagination to the test.)

However in a system like that, nothing interesting would happen after the first step. All free spaces are occupied by aliens immediately and they will just sit there forever.

Let’s create a more interesting system, this time by adding a condition to the existing rule.

space.to(alien).count(alien, 1);

By calling the method count we are stating that every time the simulation attempts to apply the rule space.to(alien) it must first count the number of whatever we pass as first parameter (alien in this case) in from the neighborhood of space and compare that with the second argument (1 in this case). If the condition is met, that is, the number of aliens in the neighborhood of space is exactly 1, then the rule is applied, otherwise it is skipped for the current step.

With this rule, we are filling the room bit by bit in an interesting pattern. It almost looks like alien tentacles! Eventually the simulation becomes stable and nothing else happens. Can we do any better?

Probably the most famous cellular automata, Game of Life rules are not that far from what we created just before, but they show emergent behavior. There’s many ways to implement Game of Life rules. Here’s one version:

// A new alien is born near a family of 3
space.to(alien).count(alien, 3);
// Underpopulation: the alien leaves the space because it feels lonely there
alien.to(space).count(alien, [0, 1]);
// Overpopulation: too many aliens!
alien.to(space).count(alien, [4, 5, 6, 7, 8]);

Feel free to come up with your own implementation! Vivarium is designed to allow you to define the same thing in multiple ways, depending on the structure and conventions you want to use.

Here’s an equivalent list of rule declarations:

// Same as before
space.to(alien).count(alien, 3);
// Alien stays there when the number of aliens around it is just right
alien.to(alien).count(alien, [2, 3]);
// Alien fell into a black hole :'(
alien.to(space);

Here we compressed the two alien.to(space) rules into one inverse rule, and we let it evolve to space otherwise, unconditionally. Try to reason on why this set of rules is equivalent to the previous one and notice two important things:

  • Rules without conditions are still meaningful, so use them when they might simplify your overall rule syntax.
  • Order of rules matters!

Did I mention it already? Order of rules matters! Yes, because the system has to evaluate rules one by one. Whichever rule meets its conditions first, will be the one evolving the cell. No more rules are evaluated for that cell until the next simulation step. In other words, rules are declared in order of priority, high to low. The first rule passing the test is the winner. Every other rule is ignored. So it’s important that you design your vivarium with this concept in mind, or your rules won’t work as expected.

Once you’ve defined all your elements, kinds, and rules, you need to finalize the vivarium by calling create. This produces the automaton object that can be passed to the simulation.

const life = vi.create();

After calling create, no more elements, kinds, or rules can be added. Think of it as locking in your design before running the experiment.

You’ve designed your vivarium — now it’s time to bring it to life! Vivarium uses WebGPU to run the simulation entirely on the GPU, which makes it fast even on large grids. All you need is a <canvas> element and a few lines of code.

First, import the setup function alongside vivarium:

import { vivarium, setup } from "@wonderyard/vivarium";

Then, get or create a canvas element and set its dimensions. The canvas width and height determine the grid size of the automaton — every pixel is a cell.

const canvas = document.getElementById("life-canvas") as HTMLCanvasElement;
canvas.style.imageRendering = "pixelated";
const size = 256; // a power of 2
canvas.width = size;
canvas.height = size;

Pass the canvas and the automaton to setup:

const { update, draw } = setup({ canvas, automaton: life });

Optionally, you can pass an initialGrid to the setup function. initialGrid is a list of element indices that will pre-populate the canvas. The list can be 1d (flat list of indices) or 2d (list of rows of indices). Its flattened length must equal width * height.

To make an example, assuming the canvas has size = 3:

const { update, draw } = setup({
canvas, // size = 3
automaton: life,
// list of rows (2d)
initialGrid: [
[0, 0, 0],
[0, 1, 0], // one alien in the middle of space
[0, 0, 0],
],
});

You can obtain the index of an element by accessing .index from its reference:

console.log(alien.index); // -> 1

The setup function returns update and draw functions. update advances the simulation by one step on the GPU (synchronous), while draw reads the result and renders it to the canvas (asynchronous). To create a continuous animation, call them inside a loop:

const loop = async () => {
update();
await draw();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);

Because update and draw are independent, you can run multiple simulation steps per frame for faster simulations, or skip update calls to pause:

const loop = async () => {
for (let i = 0; i < simulationSpeed; i++) {
update();
}
await draw();
requestAnimationFrame(loop);
};

For convenience, setup also returns evolve, which is a shorthand for calling update() + await draw().

That’s it! Your vivarium is now running on the screen.

Here is a complete example implementing Game of Life from start to finish:

import { setup, vivarium } from "@wonderyard/vivarium";
/* Create */
const vi = vivarium();
const space = vi.element("space", "#04153b");
const alien = vi.element("alien", "#34d399");
// An alien is born if there's a family of 3 in the area.
space.to(alien).count(alien, 3);
// The alien stays if the area is neither too empty nor too crowded...
alien.to(alien).count(alien, 2, 3);
// ...otherwise the alien will leave the area forever.
alien.to(space);
const life = vi.create();
/* Run */
// Get an existing canvas (or you could create one)
const canvas = document.getElementById("life-canvas") as HTMLCanvasElement;
canvas.style.imageRendering = "pixelated";
// Set the canvas size (must be a power of 2). This will be the automaton size as well.
const size = 256;
canvas.width = size;
canvas.height = size;
// Pass the canvas and the automaton you created to the setup function:
const { update, draw } = setup({ canvas, automaton: life });
// Create a simple loop that updates and draws the simulation:
const loop = async () => {
update();
await draw();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);

Now that you know the basics, explore the Concepts section to learn about all the features Vivarium has to offer, including different neighborhood types, kinds, advanced conditions, helper functions, and accept strategies.