Cellular Automata - Instructions

A visual state simulation for practicing generated behaviour testing, JavaScript console exploration, and browser-based game hacking.

About Page

Cellular Automata is useful for exploring visual state, rules, and generated behaviour over time.

Use it to practice:

  • modelling visual state transitions
  • changing rule inputs
  • observing repeatability
  • inspecting JavaScript state
  • writing small console bots
  • controlling simulation setup and teardown
  • deciding what can be asserted in a visual simulation

Overview And Videos

These instructions are based on the Hacking JavaScript Games - Cellular Automata overview, which includes supporting notes and videos.

Supporting videos:

Exploratory Testing

Change one variable at a time and record how the visual behaviour changes. If a seed or rule can be repeated, compare repeated runs.

Console Hacking Exercises

The page exposes the simulation objects globally, so the browser console is the main testing tool.

Start by finding these objects and functions:

  • world
  • worldSizes
  • addLifeForms
  • startGame
  • stopGame
  • startInvasion
  • stopInvasion
  • clearCanvas
  • drawCurrentWorld
  • LifeForm
  • getRandomInt

Explore the World

The world object has a lot of config and functions, you can just type world into the JavaScript console and you’ll see it all listed.

Since they are there you can call them or change them.

e.g.

  • world.wrapWorld=false; set the world such that there is an edge, rather than wrapping

You’ll see other config variables and methods used below but, explore what you see and experiment to get the most out of this particular playground.

Change Colour

Change the living-cell colour:

world.entityColour = "#111111";

Set it back to default colour:

world.entityColour = "#FF0000";

Create a colour bot:

var colourChangeBot = setInterval(function() {
    world.entityColour = "#" + (("000000" +
        getRandomInt(0, 0xFFFFFF).toString(16)).substr(-6));
}, 1000);

Stop it with:

clearInterval(colourChangeBot);

You can make the bot faster or slower by changing the interval. Be careful with very fast colour changes because they can create flashing visuals.

Control The Invasion

Stop the current invasion:

stopInvasion();

Start a new invasion every 100 milliseconds:

startInvasion(100);

Add a single glider:

addLifeForms.glider(world, 10, 0);

Create a random glider invasion:

var gliderInvasion = setInterval(function() {
    addLifeForms.glider(world,
        getRandomInt(0, world.xSize - 50),
        getRandomInt(0, world.ySize - 50));
}, 100);

Stop it with:

clearInterval(gliderInvasion);

Change World Size

The rendered size and the simulation size are related but separate. Changing only worldSizes changes scale calculations, but the world also needs to know its new dimensions.

Try a small black-and-white world:

worldSizes.myWorldWidth = 64;
worldSizes.myWorldHeight = 48;
world.xSize = worldSizes.myWorldWidth;
world.ySize = worldSizes.myWorldHeight;
world.entityColour = "#000000";
worldSizes.calculateScales();

stopInvasion();
stopGame();
startGame(1000);
startInvasion(3000);

Then try a larger world and watch the performance impact:

worldSizes.myWorldWidth = 1000;
worldSizes.myWorldHeight = 1000;
world.xSize = 1000;
world.ySize = 1000;
worldSizes.calculateScales();

Change Edge Wrapping

The world wraps at the edges by default, so life forms that interact across one edge can continue from the opposite edge.

Turn wrapping off:

world.wrapWorld = false;

Turn wrapping back on:

world.wrapWorld = true;

Try placing a pattern near an edge with wrapping on and off, then compare how the population changes.

Create A Random World

Take control of the running simulation:

stopGame();
stopInvasion();
world.population = [];
clearCanvas();

Seed random life forms:

for (var itemLoop = 0; itemLoop < 500; itemLoop++) {
    world.addToPopulation(new LifeForm().move(0,
        getRandomInt(0, world.xSize),
        getRandomInt(0, world.ySize)));
}

See the starting position:

drawCurrentWorld();

Start the simulation:

startGame(5);

Create Text As Life

One way to create custom patterns is to draw text on the canvas, read back the pixels, and turn those pixels into life forms.

stopInvasion();
stopGame();
world.population = [];
clearCanvas();

context.font = "14px verdana";
context.strokeStyle = "#FF0000";

var padding = String.fromCharCode(8202) + String.fromCharCode(8202);
context.strokeText("TEST".split("").join(padding), 0, 15);
context.strokeText("PAGES".split("").join(padding), 0, 30);

Convert the visible pixels to blocks:

var imageData = context.getImageData(0, 0, 200, 60);
var scale = 4;
var imageX = 0;
var imageY = 0;

for (var i = 0; i < imageData.data.length; i += 4) {
    if (imageData.data[i] !== 0 ||
        imageData.data[i + 1] !== 0 ||
        imageData.data[i + 2] !== 0) {
        addLifeForms.block(world, imageX * scale, imageY * scale);
    }

    imageX++;

    if (imageX >= 200) {
        imageX = 0;
        imageY++;
    }
}

startGame(5);

Hey, did you notice you can add #interactive on the url? What does that do? Does that change your test approach now?

Other Activities

The Cellular Automata is a blank canvas, literally clearCanvas() so what you do with it is really only limited by your imagination.

AI can easily help you write the code to build patterns and explore cellular automata, or look around the web for algorithms to apply.

Life-like Rules

The default simulation behaves like Conway’s Game of Life. If world.rulestring is empty, the page uses the hard-coded Conway rules in the JavaScript:

  • a live cell survives with 2 or 3 neighbours
  • a dead cell is born with exactly 3 neighbours
Control the Automata Rules

The page can also use Life-like rulestring notation, commonly used by tools such as Golly and described by the LifeWiki Life-like cellular automata guide.

The supported format is:

B<birth neighbour counts>/S<survival neighbour counts>

For example the default Conway Life rules are represented as follows:

B3/S23

This means:

  • B3 - a dead cell becomes alive when it has 3 live neighbours
  • S23 - a live cell survives when it has 2 or 3 live neighbours

Try these examples:

Name Rulestring Notes
Conway Life B3/S23 The default behaviour
HighLife B36/S23 Similar to Life, with extra births on 6 neighbours
Seeds B2/S Cells are born with 2 neighbours and never survive
Life Without Death B3/S012345678 Live cells never die
Day & Night B3678/S34678 Dense, symmetric-looking behaviour
Maze B3/S12345 Maze-like growth
Coral B3/S45678 Coral-like edges
Replicator B1357/S1357 Good for exploring repeating structures
34 Life B34/S34 A small variation from the default

Change the rule set from the console:

world.rulestring = "B36/S23";

Reset to the hard-coded default rules:

world.rulestring = "";

To compare rules, stop the running simulation, seed the same starting position, draw it, then start the simulation again.

stopGame();
stopInvasion();
world.population = [];
clearCanvas();

addLifeForms.glider(world, 10, 10);
drawCurrentWorld();

world.rulestring = "B36/S23";
startGame(100);

Colour Based on Age

If world.entityColour is an array, the world uses the cell age to choose a colour. Age 0 uses the first colour, age 1 uses the second colour, and older cells keep using the final colour in the list.

world.entityColour = [
    "#fff7bc",
    "#fec44f",
    "#fe9929",
    "#d95f0e",
    "#993404"
];
Colour Cycle Bot

You can rotate that palette over time to make the age bands move.

Rotate the colours every 250 milliseconds:

var colourCycleBot = setInterval(function() {
    var firstColour = world.entityColour.shift();
    world.entityColour.push(firstColour);
}, 250);

Stop the bot:

clearInterval(colourCycleBot);

Rotate in the opposite direction:

var reverseColourCycleBot = setInterval(function() {
    var lastColour = world.entityColour.pop();
    world.entityColour.unshift(lastColour);
}, 250);

Stop the reverse bot:

clearInterval(reverseColourCycleBot);

Seeding The World With Fractals

We can see the world Algorithmically and fractals are a good source of algorithmically generated images.

Sierpinski Triangle

A Simple Algorithm

This clears the world and draws a simple Sierpinski triangle from the 0,0 point.

Stop the simulation and invasion, empty the population, and clear the canvas.

stopGame();
stopInvasion();
world.population = [];
clearCanvas();

Choose the largest power-of-two square that fits the world.

var triangleSize = 1;
while (triangleSize * 2 <= Math.min(world.xSize, world.ySize)) {
triangleSize *= 2;
}

Seed a Sierpiński triangle, then see what it looks like.

for (var y = 0; y < triangleSize; y++) {
    for (var x = 0; x < triangleSize; x++) {
        if ((x & y) === 0) {
            world.addToPopulation(new LifeForm().move(0, x, y));
        }
    }
}

drawCurrentWorld();

Now start the generation to refresh every 5 milliseconds.

startGame(5);
A 'normal' vertical Sierpiński triangle
stopGame();
stopInvasion();
world.population = [];
clearCanvas();

const borderX = Math.ceil(world.xSize * 0.1);
const borderY = Math.ceil(world.ySize * 0.1);
const usableWidth = world.xSize - borderX * 2;
const usableHeight = world.ySize - borderY * 2;

const rows = Math.min(
    usableHeight,
    Math.floor((usableWidth + 1) / 2)
);

if (rows < 2) {
    throw new Error("The world is too small.");
}

const triangleWidth = rows * 2 - 1;
const startX = Math.floor((world.xSize - triangleWidth) / 2);
const startY = Math.floor((world.ySize - rows) / 2);

for (let row = 0; row < rows; row++) {
    for (let column = 0; column <= row; column++) {
        if ((column & (row - column)) === 0) {
            const x = startX + rows - 1 - row + column * 2;
            const y = startY + row;

            world.addToPopulation(
                new LifeForm().move(0, x, y)
            );
        }
    }
}

drawCurrentWorld();
startGame(5);
A centred Sierpiński triangle

If the screen is not a power of two size then we really want to force the algorithm to do that for us.

stopGame();
stopInvasion();
world.population = [];
clearCanvas();

const borderX = Math.ceil(world.xSize * 0.1);
const borderY = Math.ceil(world.ySize * 0.1);
const usableWidth = world.xSize - borderX * 2;
const usableHeight = world.ySize - borderY * 2;

const maxRows = Math.min(
                        usableHeight,
                        Math.floor((usableWidth + 1) / 2)
                );

if (maxRows < 2) {
    throw new Error("The world is too small.");
}

const rows = 2 ** Math.floor(Math.log2(maxRows));
const triangleWidth = rows * 2 - 1;
const startX = Math.floor((world.xSize - triangleWidth) / 2);
const startY = Math.floor((world.ySize - rows) / 2);

for (let row = 0; row < rows; row++) {
    for (let column = 0; column <= row; column++) {
        if ((column & (row - column)) === 0) {
            const x = startX + rows - 1 - row + column * 2;
            const y = startY + row;

            world.addToPopulation(new LifeForm().move(0, x, y));
        }
    }
}

drawCurrentWorld();
startGame(5);

I often paste the startGame(5) separately because I want to see the start state revealed by drawCurrentWorld() first.