Conway's Game of Life Through Time

Monday, April 1, 2024

This is a short post. I recently saw a video of Conway's Game of Life on Twitter that had all of the generations stacked on one another through time. It looked awesome and I was inspired to recreate it. Unfortunately, I cannot find the original post again, so I am unable to credit the creator. If anyone does come across it, please send it my way so I can give proper attribution.

As you may know, we love Bevy here, so this post uses Rust and Bevy.

What We Are Building
====================================================================================================================================================================================================================================================================================================================================

Each new generation is continually stacked on top of the previous one to show the evolution of our cellular automata through time.

The Code
====================================================================================================================================================================================================================================================================================================================================

Let's keep things brief.

const GAME_SIZE: usize = 128;
const STEP_HEIGHT: f32 = 0.35;
const CELL_SIZE: f32 = 1.;

#[derive(Resource)]
struct GameState(Box<[[bool; GAME_SIZE]; GAME_SIZE]>);

fn create_random_state() -> Box<[[bool; GAME_SIZE]; GAME_SIZE]> {
    let mut tiles = Box::new([[true; GAME_SIZE]; GAME_SIZE]);
    let mut rng = rand::thread_rng();
    for i in 0..GAME_SIZE {
        tiles[i].try_fill(&mut rng).unwrap();
    }
    tiles
}

impl GameState {
    fn new() -> Self {
        Self(create_random_state())
    }
}

#[derive(Resource)]
struct GameStep(usize);

impl GameStep {
    fn new() -> Self {
        Self(0)
    }
}

fn main() {
    App::new()
        .add_plugins((DefaultPlugins, CustomMaterialPlugin))
        .insert_resource(GameState::new())
        .insert_resource(GameStep::new())
        .add_systems(Startup, setup)
        .add_systems(Update, handle_reset)
        .add_systems(Update, step.after(handle_reset))
        .add_systems(Update, move_camera)
        .run();
}

fn setup(mut commands: Commands, mut meshes: ResMut<Assets<Mesh>>) {
    commands.spawn((
        meshes.add(Cuboid::new(CELL_SIZE, STEP_HEIGHT, CELL_SIZE)),
        SpatialBundle::INHERITED_IDENTITY,
        InstanceMaterialData(Vec::new()),
        NoFrustumCulling,
    ));
    commands.spawn(Camera3dBundle {
        transform: Transform::from_xyz(
            0.0,
            GAME_SIZE as f32 / 5.,
            GAME_SIZE as f32 + (GAME_SIZE as f32 / 4.),
        )
        .looking_at(
            Vec3 {
                x: 0.,
                y: -1. * GAME_SIZE as f32 / 3.,
                z: 0.,
            },
            Vec3::Y,
        ),
        ..default()
    });
}

Here we set up the game. We have two resources, GameState and GameStep, that track the generation (step) we are on and the state of our game.

We represent the state of our game as a 2D array where each cell is either alive or dead (represented by true or false).

Notice that we include a non-default plugin called CustomMaterialPlugin. This plugin allows us to use instances. In other words, instead of creating a mesh for each cell, we create one mesh and an "instance" of it wherever we want to display it. This is much more performant. I don't have enough knowledge about shaders to explain it in more detail, but the code for it was taken directly from Bevy's instancing example

Our step sytem progresses the game:

fn step(
    mut game_state: ResMut<GameState>,
    mut game_step: ResMut<GameStep>,
    mut instance_query: Query<&mut InstanceMaterialData>,
) {
    game_step.0 += 1;
    let mut instance = instance_query.single_mut();
    let mut next_tile_state = game_state.0.clone();
    let offset = (GAME_SIZE / 2) as f32 * CELL_SIZE * -1.;
    for i in 0..GAME_SIZE {
        for ii in 0..GAME_SIZE {
            let check_row = |row: usize, check_center: bool| {
                let mut n_count = 0;
                if ii > 0 && game_state.0[row][ii - 1] {
                    n_count += 1;
                }
                if check_center && game_state.0[row][ii] {
                    n_count += 1;
                }
                if ii + 1 < GAME_SIZE && game_state.0[row][ii + 1] {
                    n_count += 1;
                }
                n_count
            };
            let n_top_count = if i > 0 { check_row(i - 1, true) } else { 0 };
            let n_middle_count = check_row(i, false);
            let bottom_n = if i + 1 < GAME_SIZE {
                check_row(i + 1, true)
            } else {
                0
            };
            let alive_n = n_top_count + n_middle_count + bottom_n;

            if game_state.0[i][ii] && alive_n < 2 {
                next_tile_state[i][ii] = false;
            }
            if game_state.0[i][ii] && (alive_n == 2 || alive_n == 3) {
                next_tile_state[i][ii] = true;
            }
            if game_state.0[i][ii] && alive_n > 3 {
                next_tile_state[i][ii] = false;
            }
            if !game_state.0[i][ii] && alive_n == 3 {
                next_tile_state[i][ii] = true;
            }

            if game_state.0[i][ii] {
                instance.push(InstanceData {
                    position: Vec3::new(
                        offset + (ii as f32 * CELL_SIZE),
                        game_step.0 as f32 * STEP_HEIGHT,
                        offset + (i as f32 * CELL_SIZE),
                    ),
                    scale: 1.0,
                    color: Color::hsla(
                        ((ii * i) % (GAME_SIZE.pow(2) / 4)) as f32 / (GAME_SIZE.pow(2) / 4) as f32
                            * 360.,
                        1.0,
                        0.5,
                        1.,
                    )
                    .as_rgba_f32(),
                });
            }
        }
    }
    game_state.0 = next_tile_state;
}

There are four rules in Conway's Game of Life:

Our step function iterates over each cell and checks if it is alive or dead. If the cell is alive, we create a new instance of our mesh at that spot. If it is dead, we do nothing.

That's it! We have two other systems: one to move the camera and another to reset everything if the r key is pressed, but the code for these isn't worth showing here.

All the code is available in the repository.

Thank you so much for reading. I hope you have an incredible day!

My Links
====================================================================================================================================================================================================================================================================================================================================

You can find me posting random opinions and updates on Twitter or lurking on LinkedIn. I love to meet new people so don't hesitate to reach out or connect.

Github
Twitter
LinkedIn
Newsletter