import matplotlib
if not hasattr(matplotlib.RcParams, "_get"):
    matplotlib.RcParams._get = dict.get

4.2.1: Exercises#

Important

For live code, launch Thebe by clicking on the in the toolbar. Your code edits are automatically saved in your browser. Use the button to export your progress to a file, to import a previously saved session, or to reset to the original code.

4.2.1.1: The base model#

Attention

This model is up-to-date with mesa version 3.5.0

%pip install mesa==3.5.0
%pip install seaborn
%pip install networkx

4.2.1.1.1: Understand the Game of Life model#

#import libraries


import mesa  # Importing the Mesa library for agent-based modeling
# Print the version of the mesa library being used
print("You are using mesa version: " + mesa.__version__)

from mesa import Model  # Importing the Model class from Mesa
from mesa.discrete_space import CellAgent, OrthogonalMooreGrid # Importing the OrthogonalMooreGrid for a grid space where multiple agents can occupy the same cell
from mesa.datacollection import DataCollector  # Importing DataCollector to collect data from the model
import seaborn as sns  # Importing Seaborn for data visualization
import pandas as pd  # Importing Pandas for data manipulation
import matplotlib.pyplot as plt  # Importing Matplotlib for plotting

Model metrics#

# Define a function to compute the number of alive cells
def compute_alive(model):
    # Count and return the number of cells that are currently alive in the model
    return sum(1 for cell in model.agents if cell.state == "Alive")

Agent class#

# Define the Cell agent, which represents a single cell in the Game of Life.
class Cell(CellAgent):
    def __init__(self, model, cell, initial_state="Alive"):
        super().__init__(model)  # Initialize the parent Agent class with the position as the unique ID
        self.cell = cell  # x and y coordinates of the cell
        self.state = initial_state  # The current state of the cell, either "Alive" or "Dead"
        self.next_state = None  # This stores the state the cell will have in the next step

    def step(self):
        # Count the number of alive neighbors
        # Get the agents which are alive directly next to the agent. 
        # Since cell.neighborhood returns a list we iterate over that list to find the alive cells.
        living_neighbors = sum(1 for neighbor in self.cell.neighborhood for agent in neighbor.agents if agent.state == "Alive")
        
        # Apply Conway's Game of Life rules to determine the next state
        if self.state == "Alive":
            # Any live cell with fewer than two live neighbors dies (underpopulation)
            # Any live cell with more than three live neighbors dies (overpopulation)
            if living_neighbors < 2 or living_neighbors > 3:
                self.next_state = "Dead"
            else:
                self.next_state = "Alive"  # Any live cell with two or three live neighbors lives on to the next generation
        else:
            # Any dead cell with exactly three live neighbors becomes a live cell (reproduction)
            if living_neighbors == 3:
                self.next_state = "Alive"
            else:
                self.next_state = "Dead"  # Otherwise, the cell remains dead
                
    # SimultaneousActivation requires the advance function to update the state of the cells after all cells have completed their steps. 
    # This function is called internally by the model. This function is only necessary when using SimultaneousActivation.
    def advance(self): 
        # Update the current state to the next state determined in the step() method
        self.state = self.next_state

Model class#

# Define the main model for the Game of Life
class LifeModel(Model):
    def __init__(self, width, height, seed=None):
        super().__init__(rng=seed)  # Initialize the parent Model class
        # Create a grid of the given width and height. The torus argument makes the grid wrap around the edges. 
        # Capacity means that the grid will only take 1 agent present in a single cell 
        self.grid = OrthogonalMooreGrid((width, height), capacity = 1, torus=True, random = self.random)
        self.grid_size = width* height
        
        # Initialize the DataCollector to collect data on the number of alive cells at each step
        self.datacollector = DataCollector(
            model_reporters={"Alive": compute_alive}
        )
        
        # Create the agent cells. By adding weights to the choices we confirm that around 25% of the cells are Alive.
        cells = Cell.create_agents(
            model = self,
            n = self.grid_size,
            initial_state = self.random.choices(["Alive", "Dead"], weights = [0.25, 0.75], k = self.grid_size),
            cell=self.grid.all_cells.cells,
        )

        # Collect data at step 0 (initial state of the model)
        self.datacollector.collect(self)

    def step(self):
        # Advance the model by one step, activating all agents simultaneously
        self.agents.do("step")
        self.agents.do("advance")
        # Collect data after agents take a step
        self.datacollector.collect(self)

You have just read how the Cell agent decides its next state from the number of live neighbours around it. Before moving on, use the cards below to fix the four rules that drive that decision firmly in mind.

Learning Activity: The four rules of Life
recall the neighbour-count condition behind each Game of Life rule.
Four flip cards, one for each rule of Conway's Game of Life as written in the Cell agent's step method.
Click a card to flip it and reveal the exact live-neighbour count that triggers the rule and whether the cell lives or dies, trying to state the condition yourself before you flip.
Takeaway: Every cell obeys the same tally of live neighbours. A live cell needs two or three to survive, fewer starves it and more crowds it out, while a dead cell springs to life on exactly three. From these four local conditions, applied to every cell at once, all of the game's behaviour emerges.

With these four conditions clear, the flow-chart task in the next subsection becomes a question of when each rule is applied within a single step.

With the four rules fresh in mind, the surest way to make them stick is to apply them. Each small neighbourhood below shows one cell (ringed) and the live neighbours around it; decide what that cell becomes on the next tick.

Learning Activity: Alive or dead next tick?
apply the four rules to decide each cell's next state from its live-neighbour count.
A set of 3 by 3 neighbourhood cards, each with a ringed centre cell, currently alive or dead, surrounded by a stated number of live neighbours.
For each card, click Alive or Dead for what the centre cell becomes on the next tick, then press Check answers.
live neighbourdead cellthe cell you decide

Applied one cell at a time, the four rules are simple bookkeeping on a neighbour count; the richness of the Game of Life comes from applying them to every cell of the grid at once, which is what the flow chart in the next task must capture.

Task: convert the base model into a flow chart#

Convert the base model into a flow chart that tells a story of what is going on. You may use pen and paper for this.

A flow chart is really just the model’s steps drawn in the right order. Before you draw your own, try rebuilding one tick of the Game of Life from its shuffled steps below.

Learning Activity: Order the model loop
reconstruct one tick of the Game of Life from shuffled steps.
The six steps of a Game of Life run, from setting up the grid through one repeating tick, presented in a shuffled pool, with the moment the grid is allowed to change deliberately in play.
Click each step in turn to drop it into the numbered slots in the order it executes, click a placed chip to send it back, and once all six slots are full press Check order.
Your order
Steps (click to place)

Notice that only the setup happens once, while counting, deciding, and committing repeat every tick, and that counting must finish before any cell is written: that reads-before-writes loop is precisely what your flow chart needs to capture.

4.2.1.1.2: Run and visualise the base model#

# Animation and plotting code
# Note: Rendering 600 frames takes about 1-2 minutes, so please be patient.
# You can reduce the number of frames to decrease the time required.
# The number of frames is set to 600, but you might need to increase it to observe behaviors that won't be apparent at frame 600.
# You do not have to understand every line in the code below. This has been prepared for you to experiment. However, it is beneficial if you understand what is going on.

from matplotlib.animation import FuncAnimation  # For creating animations
from IPython.display import HTML  # For displaying animations inline in Jupyter notebooks
import seaborn as sns  # For data visualization
import matplotlib  # For general plotting
import matplotlib.pyplot as plt  # For creating plots
import matplotlib.colors as mcolors  # For handling colors in plots
import time  # For tracking execution time

# Increase the limit for embedding animations in notebooks
matplotlib.rcParams['animation.embed_limit'] = 2**128

start_time = time.time()  # Record the start time to measure execution duration

# Initialize a new Game of Life model
model = LifeModel(50, 50)  # Replace these dimensions as needed
model.steps = 1  # Set the initial step count to 1

# Create a figure with one subplot for displaying the grid
fig, ax = plt.subplots(figsize=(5, 5), dpi=100)

# Create a custom color map: light grey for dead cells, green for alive cells
cmap = mcolors.ListedColormap(['lightgrey', 'green'])
bounds = [0, 1, 2]  # Define boundaries for the color map
norm = mcolors.BoundaryNorm(bounds, cmap.N)  # Normalize the color map

# Function to plot the grid at each step
def plot_grid(model):
    ax.clear()  # Clear the axis to prepare for the new plot
    # Initialize a grid filled with 0s (dead cells)
    grid = [[0 for _ in range(model.grid.width)] for _ in range(model.grid.height)]
    
    # Update the grid with alive cells (set to 1)
    for cell in model.agents:
        x, y = cell.cell.coordinate     #Get the x and y coordinate of the cell
        grid[x][y] = 1 if cell.state == 'Alive' else 0
    
    # Use Seaborn to create a heatmap of the grid
    sns.heatmap(grid[::-1], cbar=False, ax=ax, square=True, cmap=cmap, norm=norm, linewidths=.5)
    
    # Set the title of the plot with the current step number
    ax.set_title(f"Game of Life: Step {model.steps}", fontsize=16)
    ax.tick_params(axis='both', which='major', labelsize=8)
    
    # Set ticks on the x and y axes
    ax.set_xticks(range(0, model.grid.width, 5))
    ax.set_yticks(range(0, model.grid.height, 5))
    ax.set_xticklabels(range(0, model.grid.width, 5))
    ax.set_yticklabels(range(model.grid.height - 1, -1, -5))

# Initial plot to display the starting grid
plot_grid(model)

# Update function for the animation
def update(frame):
    if frame > 0:
        model.step()  # Advance the model by one step
    plot_grid(model)  # Update the plot with the new grid state

# Create the animation, updating every frame
anim = FuncAnimation(fig, update, frames=600, repeat=False)

# Display the animation in the notebook
output = HTML(anim.to_jshtml())

end_time = time.time()  # Record the end time to calculate total duration

elapsed_time = end_time - start_time  # Calculate elapsed time
print(f"Time taken: {elapsed_time} seconds")  # Output the time taken to complete the process

output  # Return the animation output to be displayed
Time taken: 47.93819212913513 seconds
Animated model output
../_images/9be1fa21eac4483df5ba443db48d0613caad250ce3bcafa1b966dde31bbe54f8.png

4.2.1.1.3: Monitor living cells over time#

# Extract data from the model's DataCollector
# Get the DataFrame containing the number of alive cells over time
alive_data = model.datacollector.get_model_vars_dataframe().reset_index()

# Rename the columns for better readability
# 'index' becomes 'Time' and 'Alive' becomes 'Number of Alive Cells'
alive_data.rename(columns={"index": "Time", "Alive": "Number of Alive Cells"}, inplace=True)

# Plotting the data using Seaborn
plt.figure(figsize=(10, 6))  # Set the size of the plot
sns.lineplot(data=alive_data, x="Time", y="Number of Alive Cells")  # Create a line plot with time on the x-axis and number of alive cells on the y-axis
plt.title('Number of Alive Cells Over Time')  # Add a title to the plot
plt.grid(True)  # Enable the grid for easier reading of the plot
plt.show()  # Display the plot
../_images/6f614e49c631c32ac8560480cdcf1af4dd9cdccefbb102aff811d4b224c30cd2.png

We can also look at the number of alive vs the number of dead cells.#

#Make a copy of the alive dataframe so that we can add a column for the dead cells
new_df = alive_data.copy()
max_amount_cells = 50 * 50 # The model was called for a grid of 50 by 50 so there are 2500 cells of which a part is alive and a part is dead.

# We subtract the number of alive cells over the total numbers of cells. These cells which aren't alive are dead.
new_df.loc[:, "Number of Dead Cells"] = max_amount_cells - new_df.loc[:, "Number of Alive Cells"]

# Plotting the data using Seaborn
plt.figure(figsize=(10, 6))  # Set the size of the plot
sns.lineplot(data=alive_data, x="Time", y="Number of Alive Cells", label = "Alive Cells")  # Create a line plot with time on the x-axis and number of alive cells on the y-axis
sns.lineplot(data = new_df, x = "Time", y = "Number of Dead Cells", label = "Dead Cells") # Add the number of dead cells to the plot
plt.title('Alive vs Dead Cells Over Time')  # Add a title to the plot
plt.grid(True)  # Enable the grid for easier reading of the plot
plt.legend()
plt.show()  # Display the plot
../_images/3daa8844644e6b3efe86a757740501b306158289de2afd0fefb9f87d1bded211.png

Observations and conclusions#

Observations:#

  • Diverse Patterns: Even with a random setup, certain recognizable patterns can emerge, such as stable configurations (still lifes), oscillating patterns, and occasionally, moving patterns like spaceships.

  • Transient Behaviour: The initial randomness can lead to a great deal of activity and change in the first few generations, but over time, the grid may stabilize into certain patterns or become mostly inactive.

  • Density Matters: The initial density of alive cells (in this case, 25%) can significantly influence the game’s progression. A higher density might lead to more rapid death due to overcrowding, while a lower density might not have enough alive cells to sustain life.

Conclusions:#

  • Emergence: Even from a random starting point, complex patterns can emerge from simple rules. This is a fundamental concept in systems and complexity theory.

  • Sensitivity to Initial Conditions: Small changes in the initial setup can lead to vastly different outcomes, a concept related to the butterfly effect in chaos theory.

  • Stability and Chaos: While some regions of the grid stabilize into unchanging patterns, others can remain in flux for longer durations, demonstrating the delicate balance between stability and chaos in dynamic systems.

One question the exercises below ask you to explore is how the starting density of live cells shapes what happens next. The lab here lets you feel that relationship directly. Before you read on, predict whether it is a sparse grid or a crowded one that leaves more living cells behind after many generations.

Learning Activity: The density lab
vary the initial live-cell density and see whether the colony explodes, dies out, or settles.
A 32 by 32 wrap-around Game of Life grid seeded at random, filling each cell alive with the probability you choose.
Set the Initial alive % slider, press Play to run the colony or Step one tick at a time, and use Re-seed to draw a fresh grid at the same density while watching the alive-cells trace and the verdict.
The colony
Initial alive % 30%
Alive cells over time
Density decides the fate. A very sparse start has too few neighbours to sustain births, so most cells die of underpopulation and the colony fades out. A very crowded start has too many neighbours, so cells die of overcrowding and it collapses almost as fast. The longest-lived, richest activity tends to sit in the middle, where births and deaths roughly balance. The same four rules produce all three fates: only the starting density differs. This is distinct from the earlier timing question of which order cells update; here every run uses the same simultaneous update, and only the initial fill changes.

The lesson to carry into the questions below is that the initial density is a genuine control on the model’s fate: sparse colonies starve, crowded colonies choke, and the most interesting, longest-lived behaviour lives in between, all from the same four rules applied to every cell at once.

Exercises#

To answer the questions below, experiment with the base model by chaging the model paramters as required by the question.

Question 1#

Impact of Density: How does changing the initial percentage of alive cells influence the game’s evolution? What happens if you start with 10% alive cells versus 90%?

Question 2#

Life Span: Is there a correlation between the initial density of alive cells and how many generations it takes for the grid to stabilize?

Question 3#

Recurring Patterns: Are there certain patterns that seem to appear more frequently than others in random setups? Why might that be the case?

Question 4#

Edge Effects: How do the edges of the grid (with the toroidal setup) influence the game’s evolution? What might happen in a non-toroidal (bounded) grid?

Question 5#

Predictability: Given a random starting configuration, is it possible to predict what the grid will look like after a certain number of steps? Why or why not?

Question 6#

stability and configurations: does the model eventually reach a stable state? Can you identify specific configurations that are stable and not moving (Still lifes) and others that are oscillating (Oscillators)?

4.2.1.2: Emergent behaviour#

Every pattern you are about to meet depends on one quiet rule of the model: all cells are updated together, from the same snapshot of the current generation. The activity below lets you compare that simultaneous update against updating cells one by one in place, and see why the model keeps the two phases self.agents.do("step") and self.agents.do("advance") separate.

Learning Activity: All at once, or one by one?
see why the Game of Life must update every cell from the same snapshot of the grid.
Two grids that start from the identical pattern, the left one updated the Mesa way by computing all next states from the current generation and only then switching them together, the right one overwritten in place row by row.
Choose a Pattern, then press Step a few times, or Play, and watch the two grids drift apart.
Pattern:
Simultaneous Mesa: step → advance
Live cells: 0
Sequential update in place, row by row
Live cells: 0
Generation 0. The two grids still match.
Why two phases? Simultaneous update means every cell reads the same snapshot of the current generation. Mesa expresses this as two passes over the agents: self.agents.do("step") lets each cell compute its next_state, and only then self.agents.do("advance") copies every next_state into state at once. A single in-place loop that changes each cell as it goes is sequential: cells updated earlier in the sweep poison the neighbour counts of those still to come, so oscillators and spaceships decay while only still lifes such as the block survive by chance. Separating step from advance is exactly what keeps the dynamics correct.

The lesson to carry into the glider below is that the two-phase update, self.agents.do("step") to compute every cell’s next state and then self.agents.do("advance") to apply them together, is not a technicality: it is what guarantees each cell reacts to the same generation, so gliders glide and blinkers blink instead of dissolving.

4.2.1.2.1: Start from a glider pattern#

The original model you saw initialised cells randomly, setting a random 25% of the grid cells to be alive. This randomness can lead to a wide variety of initial configurations and thus a wide variety of evolutions. However, due to the inherent unpredictability of random setups, it’s hard to anticipate or study specific behaviours.

The Glider model, on the other hand, starts with a very specific initial configuration, the Glider pattern. This allows us to study this particular pattern in detail, observe its behaviour, and understand its properties. By focusing on a specific, non-random initial state, we can make more targeted observations and draw more specific conclusions about the system’s behaviour.

from mesa import Model  # Importing the Model class from Mesa
from mesa.discrete_space import CellAgent, OrthogonalMooreGrid # Importing the OrthogonalMooreGrid for a grid space where multiple agents can occupy the same cell
from mesa.datacollection import DataCollector  # Importing DataCollector to collect data from the model
import seaborn as sns  # Importing Seaborn for data visualization
import pandas as pd  # Importing Pandas for data manipulation
import matplotlib.pyplot as plt  # Importing Matplotlib for plotting
# Define the Cell agent, which represents a single cell in the Game of Life.

class Cell(CellAgent):
    def __init__(self, model, cell, initial_state="Dead"):
        super().__init__(model)
        self.cell = cell  # x and y coordinates of the cell
        self.state = initial_state  # The state can be "Alive" or "Dead"
        self.next_state = None  # This stores the state for the next step

    def step(self):
        # Count the number of alive neighbors
        living_neighbors = sum(1 for neighbor in self.cell.neighborhood for agent in neighbor.agents if agent.state == "Alive")

        # Apply Conway's Game of Life rules to determine the next state
        if self.state == "Alive":
            # Underpopulation or overpopulation: cell dies
            if living_neighbors < 2 or living_neighbors > 3:
                self.next_state = "Dead"
            else:  # Stays alive with 2 or 3 neighbors
                self.next_state = "Alive"
        else:
            # Reproduction: a dead cell with exactly 3 living neighbors becomes alive
            if living_neighbors == 3:
                self.next_state = "Alive"
            else:  # Remains dead
                self.next_state = "Dead"

    # SimultaneousActivation requires the advance function to update the state of the cells after all cells have completed their steps. 
    # This function is called internally by the model. This function is only necessary when using SimultaneousActivation.
    def advance(self): 
        # Update the current state to the next state determined in the step() method
        self.state = self.next_state

# Define a function to compute the number of alive cells in the model
def compute_alive(model):
    return sum(1 for cell in model.agents if cell.state == "Alive")

# Define the main model
class LifeModelGlider(Model):
    def __init__(self, width, height, seed=None):
        super().__init__(rng=seed)
        # Create a grid of the given width and height with wrap-around (torus) behavior.
        # OrthogonalMooreGrid allows multiple agents to occupy the same cell, though not required here.
        self.grid = OrthogonalMooreGrid((width, height), capacity = 1, torus=True, random = self.random)
            
        self.grid_size = int(width * height)
        
        # Initialize the DataCollector to collect data on the number of alive cells at each step
        self.datacollector = DataCollector(
            model_reporters={"Alive": compute_alive}
        )

        # Glider Configuration: Initial positions of alive cells to form a "glider" pattern
        alive_coords = [(1, 0), (2, 1), (0, 2), (1, 2), (2, 2)]

        # Place all cells on the grid
        cells = Cell.create_agents(
            model = self,
            n = self.grid_size,
            cell = self.grid.all_cells.cells        
        )
        
        # Making the cells alive which are neeed for the glider pattern
        for cell in self.agents:
            if cell.cell.coordinate in alive_coords:
                cell.state = "Alive" 

        # Collect data at step 0 (initial state of the model)
        self.datacollector.collect(self)

    def step(self):
        # Advance the model by one step, activating all agents simultaneously
        self.agents.do("step")
        self.agents.do("advance")
        # Collect data after agents take a step
        self.datacollector.collect(self)

4.2.1.2.2: Run and visualise the glider model#

# You do not have to understand every line in the code below. This has been prepared for you to experiment. However, it is beneficial if you understand what is going on.

# Import necessary libraries for animation and plotting
from matplotlib.animation import FuncAnimation
from IPython.display import HTML
import seaborn as sns
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import time

# Increase the embedding limit for animations in the notebook
matplotlib.rcParams['animation.embed_limit'] = 2**128  

start_time = time.time()  # Start timing the animation creation process

# Initialize a new model instance of LifeModelGlider with a 50x50 grid
model = LifeModelGlider(50, 50)  # You can adjust the grid dimensions as needed
model.steps = 1  # Set the initial step count to 1

# Create a figure and axis for the plot
fig, ax = plt.subplots(figsize=(5, 5), dpi=100)

# Create a custom color map for the cells: lightgrey for dead cells, green for alive cells
cmap = mcolors.ListedColormap(['lightgrey', 'green'])
bounds = [0, 1, 2]  # Define the boundaries for the colors in the colormap
norm = mcolors.BoundaryNorm(bounds, cmap.N)  # Map bounded ranges to the colormap

# Function to plot the grid at each step
def plot_grid(model):
    ax.clear()  # Clear the axis to update the plot
    # Create an empty grid based on the model's dimensions
    grid = [[0 for _ in range(model.grid.width)] for _ in range(model.grid.height)]
    # Populate the grid based on the state of each cell
    for cell in model.agents:
        x, y = cell.cell.coordinate  # Get the x and y coordinates from the cells
        grid[y][x] = 1 if cell.state == 'Alive' else 0
    
    # Plot the grid using seaborn's heatmap function
    sns.heatmap(grid[::-1], cbar=False, ax=ax, square=True, cmap=cmap, norm=norm, linewidths=.5)
    ax.set_title(f"Game of Life: Step {model.steps}", fontsize=16)  # Set the title to show the current step
    ax.tick_params(axis='both', which='major', labelsize=8)  # Reduce font size of tick labels
    
    # Set the x and y ticks to start at 0 and end at the grid size, with a step of 5
    ax.set_xticks(range(0, model.grid.width, 5))
    ax.set_yticks(range(0, model.grid.height, 5))
    ax.set_xticklabels(range(0, model.grid.width, 5))
    ax.set_yticklabels(range(model.grid.height - 1, -1, -5))

# Initial plot of the grid before animation starts
plot_grid(model)

# Update function for the animation, called for each frame
def update(frame):
    if frame > 0:  # Avoid stepping the model on the first frame
        model.step()  # Advance the model by one step
    plot_grid(model)  # Re-plot the grid after updating the model

# Create the animation using FuncAnimation
anim = FuncAnimation(fig, update, frames=100, repeat=False)  # Set the number of frames and whether to repeat

# Render the animation as an HTML video in the notebook
output = HTML(anim.to_jshtml())

end_time = time.time()  # End timing the animation creation process

# Calculate and print the elapsed time for the animation creation
elapsed_time = end_time  - start_time
print(f"Time taken: {elapsed_time} seconds")

# Display the animation
output
Time taken: 8.22642707824707 seconds
Animated model output
../_images/14bc0da24c9466cc21d1e0529c9ff6adccef54789ba65c1393adc146252dbdb8.png

4.2.1.2.3: Monitor living cells in the glider model#

# Extract data from the model's DataCollector
# The datacollector collects model variables at each step of the simulation
alive_data = model.datacollector.get_model_vars_dataframe().reset_index()

# Rename the columns for better readability
# 'index' is renamed to 'Time' and 'Alive' to 'Number of Alive Cells'
alive_data.rename(columns={"index": "Time", "Alive": "Number of Alive Cells"}, inplace=True)

# Plotting the number of alive cells over time using Seaborn
plt.figure(figsize=(10, 6))  # Set the size of the plot
sns.lineplot(data=alive_data, x="Time", y="Number of Alive Cells")  # Create a line plot
plt.title('Number of Alive Cells Over Time')  # Add a title to the plot
plt.grid(True)  # Enable grid lines for better readability
plt.show()  # Display the plot
../_images/0cb82d713156c5b453995b9689775f3ec90d8b3385d5f744a8fc2cda91c63372.png

Observations and conclusions#

Observations:#

  • Predictable Motion: Unlike random configurations, the glider shows a distinct and predictable pattern of movement. It moves diagonally across the grid, replicating its shape every 4 steps.

  • Conservation of Cells: While the glider pattern evolves over time, the total number of “Alive” cells remains constant at 5.

  • Repeating Pattern: The glider goes through a cycle of four distinct patterns, returning to its original configuration but shifted by one cell both up and to the right.

  • Boundary Behaviour: If a toroidal grid (a grid that wraps around) is not used, the glider will eventually hit the grid boundaries and get “stuck”, thus ceasing its characteristic movement. On a toroidal grid, the glider will continue its diagonal movement indefinitely.

Conclusions:#

  • Simple Rules, Complex Behaviour: The glider’s behaviour arises from the simple rules of the Game of Life. This demonstrates how complexity can emerge from simplicity, a fundamental concept in systems modelling.

  • Stability and Motion: The glider is a stable pattern, not in the sense that it remains static, but in the sense that it doesn’t decay over time. It also demonstrates how some patterns in the Game of Life can “move” across the grid.

  • Importance of Initial Conditions: The specific configuration of the glider’s cells is crucial for its behaviour. A slight change in the initial configuration can lead to a completely different outcome. This underlines the sensitivity of systems to initial conditions.

  • Emergence in Systems Modelling: The glider is a testament to the concept of emergence, where higher-level patterns arise from the interactions of lower-level entities. Such emergent phenomena are central to many fields, from biology to economics.

Exercises: implement different starting configurations#

You have already experimented with the Glider pattern, observing its intriguing behaviour as it moves diagonally across the grid. To dive deeper into the Game of Life, it’s now time to explore other classic patterns and observe their unique behaviours.

Using the provided Game of Life model, set up the following classic patterns as your initial configurations. Observe how each pattern evolves over time.

Always refer to wikipedia for more information about the different configurations: https://en.wikipedia.org/wiki/Conway’s_Game_of_Life

Before you code these configurations, it is worth being able to name what each one does. The activity below sorts the classic patterns into the three behavioural families the Game of Life is known for.

Learning Activity: Name that pattern
classify each classic Game of Life pattern by how it behaves over time.
Six classic Game of Life patterns, from the Block and Loaf to the Blinker, Pulsar, Glider and lightweight spaceship, each described by how it behaves over time.
For each pattern, choose its family from Still life, Oscillator or Spaceship, then press Check.
Block, a 2×2 square of live cells that sits unchanged forever.
Loaf, a lopsided ring of cells that also stays perfectly still.
Blinker, a row of three cells that flips between horizontal and vertical every step.
Pulsar, a large symmetric figure that pulses through a repeating cycle in place.
Glider, a 5-cell shape that reappears every four steps one cell diagonally along.
LWSS, the lightweight spaceship, which glides steadily straight across the grid.
A pattern's family is defined purely by its motion over time. Still lifes (Block, Loaf) are frozen; oscillators (Blinker, Pulsar) cycle back to the same cells in the same place; and spaceships (Glider, LWSS) cycle back to the same shape but displaced, so they travel across the grid. All three arise from the very same four rules; only the starting arrangement differs.

Knowing a pattern’s family tells you what to expect on screen: a still life will not move, an oscillator will blink in place, and a spaceship will drift steadily across the grid.

Configuration 1: Loaf#

. # # .
# . . #
# . # .
. # . .

Note:

  • ‘#’ is an alive cell

  • ‘.’ is a dead cell

  • You can place this configuration wherever you want on the grid. for example, the loaf configuration can be translated into: alive_coords = (1,0), (0,1), (2,1), (0,2), (3,2), (1,3), (2,3)

This is how you do the translation:

. # # .     -->     . (1,3) (2,3) .
# . . #     -->     (0,2) . . (3,2)
# . # .     -->     (0,1) . (2,1) .
. # . .     -->     . (1,0) . .
from mesa import Model  # Importing the Model class from Mesa
from mesa.discrete_space import CellAgent, OrthogonalMooreGrid # Importing the OrthogonalMooreGrid for a grid space where multiple agents can occupy the same cell
from mesa.datacollection import DataCollector  # Importing DataCollector to collect data from the model
import seaborn as sns  # Importing Seaborn for data visualization
import pandas as pd  # Importing Pandas 1for data manipulation
import matplotlib.pyplot as plt  # Importing Matplotlib for plotting
# Define the Cell agent, which represents a single cell in the Game of Life.

class Cell(CellAgent):
    def __init__(self, model, cell, initial_state="Dead"):
        super().__init__(model)
        self.cell = cell  # x and y coordinates of the cell
        self.state = initial_state  # The state can be "Alive" or "Dead"
        self.next_state = None  # This stores the state for the next step

    def step(self):
        # Count the number of alive neighbors
        living_neighbors = sum(1 for neighbor in self.cell.neighborhood for agent in neighbor.agents if agent.state == "Alive")

        # Apply Conway's Game of Life rules to determine the next state
        if self.state == "Alive":
            # Underpopulation or overpopulation: cell dies
            if living_neighbors < 2 or living_neighbors > 3:
                self.next_state = "Dead"
            else:  # Stays alive with 2 or 3 neighbors
                self.next_state = "Alive"
        else:
            # Reproduction: a dead cell with exactly 3 living neighbors becomes alive
            if living_neighbors == 3:
                self.next_state = "Alive"
            else:  # Remains dead
                self.next_state = "Dead"

    # SimultaneousActivation requires the advance function to update the state of the cells after all cells have completed their steps. 
    # This function is called internally by the model. This function is only necessary when using SimultaneousActivation.
    def advance(self): 
        # Update the current state to the next state determined in the step() method
        self.state = self.next_state

# Define a function to compute the number of alive cells in the model
def compute_alive(model):
    return sum(1 for cell in model.agents if cell.state == "Alive")

# Define the main model
class LifeModel(Model):
    def __init__(self, width, height, alive_coords: list = None, seed=None):
        super().__init__(rng=seed)
        # Create a grid of the given width and height with wrap-around (torus) behavior.
        # OrthogonalMooreGrid allows multiple agents to occupy the same cell, though not required here.
        self.grid = OrthogonalMooreGrid((width, height), capacity = 1, torus=True, random = self.random)
        self.alive_coords = alive_coords
        self.grid_size = int(width * height)
        
        # Initialize the DataCollector to collect data on the number of alive cells at each step
        self.datacollector = DataCollector(
            model_reporters={"Alive": compute_alive}
        )

        # Place all cells on the grid
        cells = Cell.create_agents(
            model = self,
            n = self.grid_size,
            cell = self.grid.all_cells.cells        
        )
        
        # Making the cells alive which are neeed for the glider pattern
        for cell in self.agents:
            if cell.cell.coordinate in alive_coords:
                cell.state = "Alive" 

        # Collect data at step 0 (initial state of the model)
        self.datacollector.collect(self)

    def step(self):
        # Advance the model by one step, activating all agents simultaneously
        self.agents.do("step")
        self.agents.do("advance")
        # Collect data after agents take a step
        self.datacollector.collect(self)
        
# Define a function to compute the number of alive cells in the model
def compute_alive(model):
    return sum(1 for cell in model.agents if cell.state == "Alive")
# answer here

Configuration 2: lightweight spaceship (LWSS)#

. # . . #
# . . . .
# . . . #
# # # # .
# answer here

Configuration 3: Blinker#

. . .
# # #
. . .

or

. # .
. # .
. # .
# answer here

Configuration 4: Toad#

. . . .
. # # #
# # # .
. . . .

# answer here

Configuration 5: Pulsar#

. . . # # # . . . # # # . . .
. . . . . . . . . . . . . . .
. # . . . . # . # . . . . # .
. # . . . . # . # . . . . # .
. # . . . . # . # . . . . # .
. . . # # # . . . # # # . . .
. . . . . . . . . . . . . . .
. . . # # # . . . # # # . . .
. # . . . . # . # . . . . # .
. # . . . . # . # . . . . # .
. # . . . . # . # . . . . # .
. . . . . . . . . . . . . . .
. . . # # # . . . # # # . . .
. . . . . . . . . . . . . . .

to help you speed up, here are the coordinates:

alive_coords = [
    (3, 1), (4, 1), (5, 1), (9, 1), (10, 1), (11, 1),
    (1, 3), (6, 3), (8, 3), (13, 3),
    (1, 4), (6, 4), (8, 4), (13, 4),
    (1, 5), (6, 5), (8, 5), (13, 5),
    (3, 6), (4, 6), (5, 6), (9, 6), (10, 6), (11, 6),
    (3, 8), (4, 8), (5, 8), (9, 8), (10, 8), (11, 8),
    (1, 9), (6, 9), (8, 9), (13, 9),
    (1, 10), (6, 10), (8, 10), (13, 10),
    (1, 11), (6, 11), (8, 11), (13, 11),
    (3, 13), (4, 13), (5, 13), (9, 13), (10, 13), (11, 13)
    ]

# answer here

Advanced task: add zombies to the Game of Life#

Task#

In this task, you are required to extend the existing Game of Life model to introduce a third state: “Zombie”. The traditional Game of Life includes cells that are either “Alive” or “Dead”, and they evolve over discrete time steps according to specific rules based on the number of alive neighbours they have. Your goal is to incorporate the following rules for the zombie cells in addition to the existing rules:

  • Zombie Creation: A cell (either “Alive” or “Dead”) turns into a “Zombie” if it has exactly 7 living neighbours.

  • Zombie Properties:

    • Zombies are not considered “alive”.

    • Zombies cannot die, since they are not “alive”.

    • Zombies cannot be revived or return to an “Alive” state.

# answer here

Steps to perform this task (only look at this after you have tried it yourself):#

  1. Zombie Creation Rule:

    • Implement the rule for turning a cell into a zombie. Modify the step method in the Cell class to include the condition where a cell turns into a zombie when it has exactly 7 living neighbours.

  2. Zombie Properties:

    • Ensure that once a cell becomes a zombie, it remains in that state permanently. Adjust the step method to prevent zombie cells from dying or turning back into living cells. Their state should remain unchanged in subsequent steps.

  3. Data Collection:

    • Update the DataCollector to track the number of zombie cells. You may need to create a new function similar to compute_alive, specifically for counting zombie cells at each time step.

  4. Visualisation:

    • Modify the visualisation part of your code to distinctly represent zombie cells. You may need to adjust the colour map and the plot_grid function to include a distinct colour or marker for zombie cells.

  5. Interpretation:

    • Reflect on the implications of introducing zombie cells into the simulation. Consider how this affects the overall dynamics of the system, the potential for new patterns to emerge, and the impact on the longevity of the simulation.

Added exercises#

Task 1: dying at a different speed#

In this exercise instead of the cells dying with 1,4,5,6,7,8 neighbours, let cells die only when they have more than 5 neighbours. Use the base model. Make necessary adjustements in the agent class so that the model behaviour changes. Then visualise the model behaviour using the grid and explain the new behaviour.

# answer here

Task 2: what is life truly?#

In this exercise instead of agents being born when surrounded by 3 other live cells let cells become alive when 2 neighbours are alive. Use the base model. Make necessary adjustements in the agent class so that the model behaviour changes. Then visualise the model behaviour using the grid and explain the new behaviour.

# answer here

Task 3: randomise cell behaviour#

In this exercise modify the behaviour of the Cell agents such that the agents die and are born for varying conditions per cell. Use the base model. Modify the agent behaviour so that the changes in the model become apparent. Make sure that the cells do not die instantly by not specifiying the ranges correctly. Use a grid visualisation to analyse the behaviour and explain what is happening.

# answer here

Completion and reflection#

Before you finish, confirm that you can:

  • Run the random and glider initial states and describe how their patterns differ.

  • Relate a visible transition to the local birth or survival rule that caused it.

  • Implement one new configuration or rule variant and monitor living cells over time.

  • Explain what is emergent rather than explicitly programmed.