Life Simulator

Grow, multiply and die: in Vanilla JavaScript

EXPERIMENTAL
Katherine Stember [CC BY 4.0 (https://creativecommons.org/licenses/by/4.0)]

A Life Simulator in Vanilla JavaScript

What's in a game? Most video-games are designed to give players the greatest possible control while being challenging and, of course, fun.

There are some exceptions, though, games that have little if no control: their entertaining side, the fun, lies on human curiosity.

Most famous of all is Conway's Game of Life from the 70's but in the decades since a lot of simulations have been built for entertainment, science and pure curiosity.

That's how my last fun-project began, out of curiosity.

Three or four years ago I was working in the mobile gaming industry and researching a bacterial life simulator to be used in a Unity game. Game development was never even started (that's how R&D works), but I remember the challenges of working in 60 frame per seconds holding thousands of digital organism under control.

The project was in C# and on an engine optimised for these purposes (most details about it are fuzzy thanks to my 64Gb brain), but I do remember clearly that visualization required generating images instead of having tens of thousands of 3D objects, no matter how simple I built them.

I decided to replicate this project in JavaScript, that meant I needed to do some thinking.

By the way, it is a simple simulation of bacteria-like organisms that do not have any drive or purpose, but follow simple mathematical random rules, with controls on environmental changes only. I believe they should not achieve consciousness, but I do not believe in capitalism and it happens every day anyway.

The basics

I have isolated the most important issues as such:

  • Even in a small representation, I could not modify and handle thousands of DOM elements without melting my browser (62500 in my planned 250px x 250px).
  • I wanted to have a smooth experience, such as frames per seconds and not seconds per frame while handling one array with tens of thousands of objects.
  • I could paint an image in a HTML <canvas> where every pixel would represent an organism.
  • I wanted unique random settings per organism such as reproductive age, lifespan, number of children that it could have, diseases.
  • All the above meant that .map(), .forEach() and other wonderful array tools were to be substituted by the good old for() loop, the most performant loop there is.

As everybody knows by now, I like to use frameworks:

That means I'm quite rusted in vanilla JS and wouldn't use it if I could help it, but because no framework could give me the same performance, I had to try and use my newly acquired skills in es6 in combination with the <canvas> tag.

Of the latter I knew little, but I did enjoy the small research for this tiny but powerful tag.

The code

I saved it as a pen and you are welcome to go and grab it, but if you just want to look at it, you could switch on the experimental bar in this page.

See the Pen Life Simulator by elvis salaris (@regrunge) on CodePen.

I won't talk about the minimal CSS, the three lines of HTML and I definitely won't touch the first 205 lines of the JS file: the extensive code to build the DOM elements, inputs and whatnot are a painful reminder of why we use frameworks.

The remaining 150 lines contain the entire code necessary to make the Life Simulator work.

Canvas and JS settings

In the abhorrent first half of the JS file there are a couple of important pieces, first the canvas initialisation...

// Makes a <canvas> tag
const canvas = document.createElement('canvas');

// fetches the context, the only thing you actually interact with
const ctx = canvas.getContext('2d');

// settings for crispiness, I believe
ctx.imageSmoothingEnabled = false;
ctx.mozImageSmoothingEnabled = false;

...then the initial settings, this is the closest thing to a manual for this game:

const colors = {
    // colors in RGB as [r, g, b]. But you knew that, right?
    blank:      [0,0,0],
    born:       [0,255,255],
    growing:    [255,0,255],
    mature:     [255,255,0],
    elder:      [127,127,255],
    disease:    [0,255,0],
};

const settings = {
    // Width and height define both the canvas and the max population
    width: 250,
    height: 250,

    // seeds an initial population
    initialPopulationNumber: 12000,

    // once mature, an organism have a probability to reproduce
    birthProbability: 0.25, // Randomized at every cycle

    // a maximum amount of kids an organism can have. Randomized.
    maximumProgeny: 4,

    // a maximum age to start the maturity. Randomized.
    maturityAge: 480,

    // maximum life span of an organism. Randomized.
    deathAge: 760,

    // an accelerator of cycles
    agingSpeed: 1,

    // the probability to be born with a disease. Randomized.
    diseaseRate: 0.25,

    // How many cycles are taken away from the lifespan
    diseaseMortality: 5,
};

The logic

I had a couple of issues with performance using setInterval() and setTimeout() and had to resort to requestAnimationFrame(), a nice little tool that seriously improved it, tenfolds.

For the same reasons I had to resort to for() loops and lookup tables of pre-generated random numbers instead of Math.random(). The culprit?

A huge array containing "null" or objects representing an organism:

initialPopulation = Array(settings.width * settings.height).fill(null);

I have tested and worked mostly on a 500px by 500px canvas that gave me a maximum population of a quarter of a million individuals and randomizing every birth initial settings and modifying the array with changes in the individuals age, random reproduction, disease and death up to 60 times every second had to be super-optimised.

The multiple frames per second generation of an image in the canvas containing a simulated organism in every pixel was, by comparison, so fast to be effortless.

Let us look at a single cycle to see what is going on.

// "pop" is the array I have filled with null or organisms
const imageMaker = (pop) => {
    // gives me the frame rate
    const t0 = performance.now();

    // keeping track of cycles, organism age by one cycle every frame
    age += parseInt(settings.agingSpeed);

    // I need to keep track of empty spaces where to spawn newborns
    const emptyIndexes = [];
    for(let f=0; f<availableSpace; f++){
        if(!pop[f]){ emptyIndexes.push(f); }
    }

    // this random sorter really sped up things
    emptyIndexes.sort(() => (0.5 - lookup()));

    // looping every single pixel (or organism/null spaces in the population array)
    for(let x=0; x<availableSpace; x++){

        // by default we got a black/blank pixel
        let element = colors.blank;

        // if there is an organism on this pixel...
        if(pop[x] !== null){

            // Let's make it age per settings
            pop[x].age += parseInt(settings.agingSpeed);

            // the organism is too old, R.I.P.
            if(pop[x].age >= pop[x].death){

                // add this newly freed index to the empty spaces array
                emptyIndexes.push(x);

                // eliminate from population pool
                pop[x] = null; 

            // the organism is old and should be coloured as such
            } else if (pop[x].age >= pop[x].elder){
                pop[x].color = colors.elder;
            } else {

                // between birth and... puberty, I guess?
                if(pop[x].age >= pop[x].born){
                    pop[x].color = colors.growing;
                }

                // maturity!
                if (pop[x].age >= pop[x].maturity){
                    pop[x].color = colors.mature;

                    // there are settings-related random probability of making a child
                    if(lookup() > 1 - settings.birthProbability && emptyIndexes.length > 0){
                        pop[x].children++;

                        // limited amount of kids destined to the organism
                        if(pop[x].children < pop[x].maxChildren){

                            // let's fill an empty hole
                            pop[emptyIndexes.pop()] = newBorn(pop[x].diseased);
                        }
                    }
                }
            }

            // without breaking previous actions check if sick...
            if(pop[x] && pop[x].diseased){

                // override any previous colour with sickness 
                pop[x].color = colors.disease;

                // shorten its lifespan per settings
                pop[x].death -= settings.diseaseMortality;
            }

            // it could have died in this loop, check again... 
            element = pop[x] ? pop[x].color : colors.blank;
        }

        // Canvas ImageData contains 4 array elements per pixels in a row
        let i = x * 4;
        imgData.data[i] = element[0];   // Red
        imgData.data[i+1] = element[1]; // Green
        imgData.data[i+2] = element[2]; // Blue
        imgData.data[i+3] = 255;        // Opacity, set to 100%
    }

    // end-ish of frame, calculating...
    const t1 = performance.now();

    // clear the canvas
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // put the huge array of pixel data as a image inside the canvas
    ctx.putImageData(imgData, 0, 0);

    // data that we should show, I guess.
    dataSpans.population.textContent = pop.filter(e => e !== null).length;
    dataSpans.frameRate.textContent = (t1 - t0).toFixed(2) + 'ms';
    dataSpans.age.textContent = age;
    dataSpans.totalBirths.textContent = totalBirths;

    // If the population is extinct... die()
    if(pop.filter(e => e !== null).length === 0){
        started = false;
        data.setAttribute('class', 'red');
    }
};

Extinction

Working on such a demanding environment for JavaScript forced me to go back to the basic of coding, striving to keep things simple and pushing all hard work on "cached" data like randoms, and for() loops.

This stressful conditions allowed me to appreciate the difference in performance of various array methods with maps as the slowest and sort as almost insignificant: it became a good benchmark tool for learning and testing.

So it was fun to build and charming to run and look at, and unless I have just created a harmful artificial intelligence, I did enjoy feeling like God.

The settings on the experimental bar are my best attempt at an equilibrium, with a good survival rate, but without filling the entire canvas with overpopulation. I challenge you to do better!

Is it bad that I feel a sense of satisfaction when every organism has died?