CSCI 150: Lab 7

The Game of Life!
Due: 11:59 PM on Tuesday, April 6 13

The purpose of this lab is to:

  • Explore the Game of Life
  • Animate a simulation
  • Practice working with classes and Object-Oriented Programming

Optional Prelab

We have put together a set of optional prelab questions (with an answer key) that will help you work through some of the ideas related to this lab before you start doing any programming. You can find these questions here in Prelab 7. It is highly recommended that you read through the prelab before working on the lab.

Part 1 - The Game of Life

life.py: 40 points, partner encouraged.

The Game of Life, created by mathematician John Conway, is what is referred to as a cellular automaton. It is a set of rules which are used to generate patterns that evolve over time. Despite the name, the Game of Life is not a game; it is really a simulation. In some ways, it can be thought of as an extremely simplified biological simulator which produces unexpectedly complex behavior.

The Game of Life is played on an infinite board made up of square cells. It takes way too long to draw an infinite board, so we'll make do with a small finite piece. Each cell can be either live or empty. We'll indicate a live cell as a red square, and a empty cell as a white one. The board begins in some initial configuration, which just mean a setting of each cell to be either live or empty (generally we'll start mostly empty cells, and only a few live cells).

A configuration of a 10-by-10 portion of the board with 9 live cells.

The board is repeatedly updated according to a set of rules, thereby generating a new configuration based on the previous configuration. Some empty cells will become live, some live cells will disappear, and some cells will be unchanged. This configuration is then updated according to those same rules, producing yet another configuration. This continues in a series of rounds indefinitely (or until you get bored of running your simulation).

Rules of Life

The rules are pretty simple: to figure out whether a cell (x,y) will be live or empty in the following round, you just look at the 8 neighbors of that cell (those that share a corner or an edge, so N, S, W, E, NW, NE, SW and SE). What happens to (x,y) next round depends on the number of its neighbors who are live and whether it is currently live or not. In particular:

  • If a cell has 4 or more live neighbors, it disappears (overcrowding).
  • If a cell has 0 or 1 live neighbors, it disappears (loneliness... *sniff*).
  • If a cell has exactly 2 live neighbors, nothing changes (if it was live, it stays live, if it was empty, it stays empty).
  • If a cell has exactly 3 live neighbors, it comes to life (I don't have a good explanation for this one, but it does seem to make for cool patterns).

For example, consider the following 3 initial configurations, and the two configurations that follow each.

Three initial configurations and two subsequent iterations.

In the first example, both live cells have only one live neighbor, so they both disappear. Any empty cell has at most two live neighbors, so no new live cells spawn. Thus in one step, there are no live cells. Clearly, at this point, the configuration is stable.

In the second example, two live cells have only one neighbor, so both disappear. But the third cell lives, and the cell to its immediate left has exactly 3 live neighbors, so it spawns. On the next iteration, we find ourselves in a case similar to the previous example, and all cells disappear.

Note that we can't set a cell to be live or empty the instant we determine its status for the subsequent round; we will likely need to know whether it is alive or empty on this round to determine the future status of other nearby cells. To see this, consider the second example. We can immediately tell that the top-most live cell will disappear. But had we set it to empty immediately, then when we got to the second live cell, it would have only had 1 live neighbor and we would have (erroneously) determined that it too must disappear. Thus it is critical that we first determine for every cell whether or not it will be live, and only after doing so update the status of each.

In the last example, all currently living cells disappear; the middle cell has too many neighbors, and the other have too few. However, four empty cells have exactly 3 live neighbors, and so those cells spawn. In the following round, there are neither cells that disappear nor cells that spawn, so we have a stable configuration. In general, a pattern that doesn't change is called a still-life.

While all of these patterns stabilized quickly, some patterns take a long time to stabilize, and some never do. Of those that never stabilize, some at least have a regularity to them; they eventually eventually repeat states. These are said to be oscillators. Others never repeat the same state again, and produce an infinite number of configurations.

Describe the Problem:

Write a program life.py that simulates the game of life for a given number of iterations, displaying the state of the board graphically at each step.

Understand the Problem:

Make sure your answers from your prelab were correct. If not, go back and figure out what went wrong.

Design an Algorithm:

We will be creating the game of life using two different classes: one for the game board, and one for each cell.

Cell Class

The Cell class represents square cells in the Game of Life. Each turn of the game, each cell will be updated based on the rules of the game. Because each cell has a different location and a different status (i.e., alive or empty), the Cell class should have four instance variables:

  • integers x and y, which represent its location the board
  • Boolean alive which is True if the cell is alive, and False if it's not, and
  • Boolean last, which stores whether or not it was alive on the last turn (this is helpful for when we are updating the board)

The Cell class should also have the following methods:

  • __init__(self, x, y), which is a constructor that sets the initial values for the self.x and self.y instance variables to be the values of the parameters x and y. It should also set both self.alive and self.last to False

  • countNeighbors(self, cells), which is a method for counting how many neighbors to this cell are currently alive. Here, the cells parameter is a list of lists of Cell objects that make up the rest of the board. As a hint,

    neighbor = cells[neighbor_x][neighbor_y]
    
    will save a particular Cell into a new variable called neighbor whose coordinates are (neighbor_x, neighbor_y) in the board. We can then use neighbor.last to get the value of the last instance variable to see whether neighbor is current alive or empty (we cannot use neighbor.alive since that may or may not have already been updated while we are updating the current self instance). Recall that each Cell has 8 neighbors; below we discuss how to handle the special cases of a Cell on the edge of the board.

  • update(self, cells), which is responsible for updating the self.alive instance variable of this Cell instance. It should use self.countNeighbors(cells) to count how many of its neighbors are currently alive, then combine that with self.alive to follow the rules of the Game of Life to decide if the cell should now be alive (self.alive = True) or empty (self.alive = False).
Dealing with Edges (Donuts!)

The edges of the board tend to be problematic. For example, if you are considering cell (WIDTH-1,HEIGHT-1) and you attempt to check the liveness of its neighbors as you would for most cells, you'll end up attempting to access positions whose indices are too large. You may have run into this problem in Lab 4 doing operations such as scroll and blur.

One natural fix is to simply create a board that is 2 units taller and 2 units wider than you actually want, and only do updates on non-border cells. This approach would work fine, but on this lab you're going to do something a bit different; we're going to use a torus rather than a plane for our game board. A torus is just the technical name for the shape of a donut.

OK, so what does that mean here? It means that if you were to walk off the right side of the board, you'd appear on the left side at the corresponding position, and vice-versa. Likewise if you walked off the top of the board, you'd appear on the bottom. This means every cell has 8 neighbors now. Consider cell (0,0). It has the 3 obvious neighbors in the directions E, SE and S. If we want to find the N neighbor, we have to wrap around the top of the board, bringing us to (0, h-1). Similarly, the NE neighbor would be (1, h-1). The NW neighbor would be (w-1, h-1), and so on.

One reason this is so handy is that if you use the mod operator appropriately, you don't need any special cases to handle edges or corners. In particular, say we are working with a Cell at location self.x and self.y. Then some of the neighbors of this Cell are at locations:


    north_y = (self.y - 1) % h
    east_x = (self.x + 1) % w
    south_y = (self.y + 1) % h
    west_x = (self.x - 1) % w

    north_neighbor = cells[self.x][north_y]
    northeast_neighbor = cells[east_x][north_y]
    east_neighbor = cells[east_x][self.y]
    # etc. for the other 5 neighbors

Alright, so what did this have to do with donuts? Well, since the top of the board is now effectively connected to the bottom of the board, you could imagine the board as a flexible square of rubber, and this connection could be represented by gluing the top edge to the bottom edge. Now we've created a rubber tube. But the left and right edges are also connected. If we bend our tube and glue these edges together, we've created a torus. Mmmm... donuts.

Board Class

The Board class will represent the collection of all of the Cells that make up the entire game. Thus, it should have one instance variable:

  • cells, which is a two dimensional list (i.e., a list of lists) that stores all of the Cell instances in the board. As described above, cells[x][y] should store the Cell located at (x, y).

The Board class should have the following methods:

  • __init__(self, width, height), which is a constructor that creates the self.cells instance variable to be a list that has width number of inner lists (each inner list corresponds to a column of the board), and each inner list has height number of Cell objects (one for each row of the column of the board). Two nested for loops might be really helpful here. Please note that this method will create the Cell instances that will be stored in that list of lists by calling the Cell constructor and passing in the correct x and y values for each Cell instance.

  • initialize(self, setup) should assign a True value to the alive instance variables of specific cells, based on the setup number passed in as the setup parameter. We provide details about what you should do for setup = 1 below; you should come up with two more interesting options for setups 2 and 3. Check out the Life Wiki for some ideas.

    The first starting configuration should include just the 5-cell pattern below, somewhere in the middle of the board. For the other two, find a couple configurations that generate interesting effects.



    The first starting configuration.


  • update(self) should first copy the value in the alive instance variable of each Cell in self.cells to the same Cell's last instance variable using two nested for loops. Then after those loops have completed, it should call the update method for each Cell, again using two nested for loops.

  • draw(self, canvas, force) which will update the drawing of the board on a Picture object (from the picture module used in Lab 3) called canvas. In particular, it should draw a rectangle with a width and height of 10 pixels for each Cell that is colored red if the Cell is alive and white if the Cell is empty. The top left corner of each rectangle depends on its Cell's x and y instance variables and the fact that each rectangle is 10 pixels wide and tall.

    Because drawing many Cells will take some time and only some will change color each time the update method is called, we are going to optimize this draw method. The force parameter should take a default value of False, and it will determine whether you draw every rectangle (when force == True) or only the rectangles for those Cells that changed status during the current turn (when force == False). To determine whether the status of a Cell changed during the current turn, we can compare their alive and last instance variables and see if those Boolean values are different.

Implement a Design

Testing your Cell Class First

To help you debug your Cell class before working on the Board class and later complete the Game of Life, we have provided a test program test_cell.py that will use the Cell class that you implemented in the life.py file. If your Cell class is implemented correctly, you should see the following output when you run the test_cell.py program:

Cell 5,6 has 2 alive neighbors
..........
..........
..........
..........
..........
.....xxx..
..........
..........
..........
..........

..........
..........
..........
..........
......x...
......x...
......x...
..........
..........
..........

..........
..........
..........
..........
..........
.....xxx..
..........
..........
..........
..........
			

Final Program Narrative

After you implement the Cell and Board classes above (and know that your Cell class is working), you should create a main function that does the following:

  1. Ask the user for a setup number

  2. Create an instance of the Board class with width of 50 and a height of 80

  3. Call the initialize method of your new Board instance with the setup number provided by the user

  4. Create a Picture object that is 500 pixels wide and 800 pixels tall for drawing the game

  5. Call the draw method of your Board instance using the Picture you just created and a force = True so that every rectangle is drawn.

  6. Using a loop that runs for 100 generations, repeatedly call the update method of your Board instance, then call its draw method with the aforementioned Picture object (and no argument for force so that takes on the default value of False) to display the changes in the Cells.

Aging Cells (Optional)

Keep track of how many rounds each live cell has been alive, and color the cell based on that age. For example, you might have newborn cells begin red but slowly turn blue as long as they stay alive.



Maintain:

Be sure to comment your code once you're done if you haven't been doing so along the way.

Part 2 - Wrap Up

Congratulations! You have finished the seventh lab. As with every lab, your last job prior to submission is to complete a brief write-up by filling out a Google Form, which is also how you submit your Honor Code statement (so please do not forget to do this).

Handin

Finally, all you need to do is submit your solution to the assignment. To do so, please click on the "Submit" button in the top right of your programming environment. This will save all of your programs for myself and the graders to look at. You are welcome and encouraged to submit partial solutions to your assignment -- you can always resubmit by pushing the "Resubmit" button in the top right, which will save the latest version of your programs. We will only grade the latest version that you submitted. By submitting multiple times, you avoid accidentially forgetting to turn in any completed part of your assignment, especially in case you become busy around the lab due date.

Grading Rubric

Cell Class (14 points)

PointsRubric Item
+2Started a Cell class
+4Correct Constructor
+4Correct countNeighbors method
+4Correct update method

Partial Credit

PointsRubric Item
+2Constructor was close, but had some errors
+2countNeighbors method was close, but had some errors
+2update method was close, but had some errors
+1Constructor was started
+1countNeighbors method was started
+1update method was started

Board Class (18 points)

PointsRubric Item
+2Started a Board class
+4Correct Constructor
+4Correct initialize method
+4Correct update method
+4Correct draw method

Partial Credit

PointsRubric Item
+2Constructor was close, but had some errors
+2initialize method was close, but had some errors
+2update method was close, but had some errors
+2draw method was close, but had some errors
+1Constructor was started
+1initialize method was started
+1update method was started
+1draw method was started

Main Function (8 points)

PointsRubric Item
+1Asks the user for a setup number
+1Creates an instance of the Board class
+1Initializes the Board
+1Creates a Picture object to draw the board
+1Draws the initial Board configuration
+1Runs for multiple steps (should be 100)
+1Updates the Board each step
+1Draws the Board each step