This assignment has been closed on December 05, 2023.
You must be authenticated to submit your files

Tutorial 8: Game of Life

O.Blazy from S. Mengel

Setup: before you start

Create a new empty project in Spyder, and call it Tutorial_8.

Now create a new program file, life.py. All the functions you write have to be added to this file.

Context and Objectives

Conway’s Game of Life is a zero-player game played usually on a two-dimensional grid and in several rounds. In every round, every point in the grid is in exactly one of two states: either it is alive or it is dead. For the next round, the state of a point is computed as follows:

Skills we will practice today: writing classes, creating objects, interactions between different classes/objects.

Exercises

Exercise 1: A Class for Points

We will play Game of Life on grids of a given finite size n x m. There are several natural ways to store the live points in every round: one could create an (nxm)-matrix in which the entries encode if a point is alive or not. However, since in most situations the number of live points is far lower than the number of dead points, this encoding is not very efficient when the grid is large.

We choose an alternative encoding in which we only remember the live points and store them in a set. To do so, we introduce a class Point that lets us encode points in the grid. For convenience we give you the beginning of the class with the __init__() method already included.

Create a class Point by copying the following:

class Point:
    """Encodes a live point in the Game of Life.
    Data attributes:
    x -- x-coordinate
    y -- y-coordinate
    """

    def __init__(self, x, y):
        self.x = x
        self.y = y

To simplify debugging your code later on, add a method __repr__(). Recall that __repr__ is supposed to give a canonical representation of the Point that will allow it to be reconstructed; this will make it very useful for debugging.

For example, given a Point with coordinates 1 and 2, this method should return the string 'Point(1, 2)'. Your method should begin with

    def __repr__(self):

Once your __repr__() method is defined, you should be able to run the following test:

In [1]: p = Point(2, 3)

In [2]: p.x
Out[2]: 2

In [3]: p.y
Out[3]: 3

In [4]: repr(p)
Out[4]: 'Point(2, 3)'

In [5]: p
Our[5]: Point(2, 3)

Afterwards, upload your file life.py:

Upload form is only available when connected

Exercise 2: Making Points Hashable

Before you work on this exercise, make sure that you remember how sets and hashing work. If you don’t remember (or, worse, don’t even know what either of these things are), then have a look at the lecture slides and the explanations at the bottom of this page.

We want to store the live points in our implementation the Game of Life in sets. In particular, every point should only be contained once in a set and we want to be able to check if a point is in a set. We can already put Points into sets, but unfortunately the behavior is not what we would like.

In [6]: a = Point(1, 2)

In [7]: b = Point(1, 2)

In [8]: s = {a, b}

In [9]: s
Out[9]: {Point(1, 2), Point(1, 2)}

In [10]: Point(1, 2) in s
Out[10]: False

In [11]: a == b
Out[11]: False

Try to understand the above behavior!

To get the behavior that we want, we have to adapt the way that Points are treated in sets. To do so, we have to change the way that they are compared and hashed.

First, write a method __eq__() to compare Points for equality. It should return True if and only if the two points have identical x- and y-coordinates. Your method should begin as follows:

    def __eq__(self, other):

Sets in python implicitly use hashing to store the elements. To make this work correctly with our points, add a method __hash__() that should begin as follows:

    def __hash__(self):

Remember that the value computed by __hash__() is an integer. Moreover, this value has to be identical for two Points whenever the points are equal with respect to __eq__().

When you are done with both __eq__() and __hash__(), you should be able to run the following test:

In [12]: a = Point(1, 2)

In [13]: b = Point(1, 2)

In [14]: a == b
Out[14]: True

In [15]: hash(a) == hash(b)
Out[15]: True

In [16]: s = {a, b}

In [17]: s  # should only contain one element, since a == b
Out[17]: {Point(1, 2)}

In [18]: Point(1, 2) in s
Out[18]: True

To avoid too many hash collisions for reasonable grid of Points make sure that when you execute

len({hash(Point(i, j)) for i in range(100) for j in range(100)})

on the ipython console, you get as an answer at least 5000.

Afterwards, upload your file life.py:

Upload form is only available when connected

Interlude: Hashing and mutable objects

Be careful! When objects are hashable, python assumes that they are never changed in any way that changes the hash value. Otherwise, strange things will happen. For example, continuing the example above, one might get

In [19]: s
Out[19]: {Point(1, 2)}

In [20]: a.x += 1

In [21]: s
Out[21]: {Point(2, 2)}

In [22]: Point(2, 2) in s
Out[23]: False

Try to understand what is problematic in the example above!

Introducing and showing the Board

Having a data structure for individual Points, we now define a class to store the whole grid which we will call Board. The class begins as follows:

class Board:
    """A board to play the Game of Life on.
    Data attributes:
    alive_points -- a set of Points
    x_size  -- size in x-direction
    y_size  -- size in y-direction
    """

    def __init__(self, x_size, y_size, points):
        self.alive_points = points
        self.x_size = x_size
        self.y_size = y_size

In contrast to the original Game of Life which is played on infinite grids, we will assume that our boards have a size that is chosen upon creation of the board. We store the size in two attributes x_size and y_size that are initialized by __init__() and represent the size in x- and y-direction, respectively. By convention, the possible values for the x-coordinate are 0, ..., x_size-1 (from left to right). Similarly, the possible values for the y-coordinate are 0, ..., y_size-1 (from top to bottom). Moreover, Board has an attribute alive_points that is filled by a third argument and stores the live points. We assume that those are always stored in a set.

Copy the beginning of the class Board above to your file life.py.

We now give you a graphical representation of Game of Life. Download the file tkview.py and run it. Afterwards you can run the program with a board that you provide as follows:

In [24]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})

In [25]: g = GraphicalLife(b, 20)

In [26]: g.mainloop()

Try it out! Also, try to create different board configurations!

Exercise 3: Dealing with the Neighbors

To compute the state of the Board in the next round, it will be useful to compute the neighbors of a Point. To this end, add a method get_neighbors to the class Point (not the Board class!) that returns a set containing all neighbors of a Point.

    def get_neighbors(self):
        """Return the neighbors of the Point as a set."""

Since a Point has no way of knowing the size of the Board it is placed on, we do not check if the neighbor is actually legal, i.e., a point on the board.

Test your method get_neighbors(). For example, on the console you should get (possible with a different order on the results)

In [27]: p = Point(2, 2)

In [28]: p.get_neighbors()
Out[28]:
{Point(1, 1),
 Point(1, 2),
 Point(1, 3),
 Point(2, 1),
 Point(2, 3),
 Point(3, 1),
 Point(3, 2),
 Point(3, 3)}

In [29]: p = Point(0, 0)

In [30]: p.get_neighbors()
Out[30]:
{Point(-1, -1),
 Point(-1, 0),
 Point(-1, 1),
 Point(0, -1),
 Point(0, 1),
 Point(1, -1),
 Point(1, 0),
 Point(1, 1)}

Note that in the second example several points are illegal because they have negative coordinates. We will take care of this now. Add a method is_legal() to the class Board that returns True if a given Point lies on the Board and False otherwise. Remember that on the board we have positions exactly for those Points whose x-coordinate is between 0 and x_size-1 and whose y-coordinate is between 0 and y_size-1.Your method should begin with

    def is_legal(self, point):
        """Check if a given Point is on the board."""

Test your method is_legal as follows:

In [31]: b = Board(5, 5, set())

In [32]: b.is_legal(Point(2, 2))
Out[32]: True

In [33]: b.is_legal(Point(0, 0))
Out[33]: True

In [34]: b.is_legal(Point(0, 5))
Out[34]: False

In [35]: b.is_legal(Point(-1, 0))
Out[36]: False

Afterwards, upload your file life.py:

Upload form is only available when connected

Exercise 4: Computing the number of neighbors

The state of a point in the next round depends on that of this round and that of its neighbors. In particular, we will have to count the number of live neighbors for cells. This is what we will do in this exercise.

Write a method number_live_neighbors() in Board that computes the number of neighbors of a given point that are alive. Your method should start as follows:

    def number_live_neighbors(self, p):
        """Compute the number of live neighbors of p on the Board."""

Hint: if s and t are sets, then s.intersection(t) returns their intersection.

Test your method number_live_neighbors(). In the console, you should get:

In [37]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})

In [38]: b.number_live_neighbors(Point(2, 2))
Out[38]: 2

In [39]: b.number_live_neighbors(Point(1, 2))
Out[39]: 1

In [40]: b.number_live_neighbors(Point(0, 0))
Out[40]: 0

Afterwards, upload your file life.py:

Upload form is only available when connected

Exercise 5: Computing the next round

Now we have everything together to go from the state of the Board in one round to that in the next round.

Write a method next_step() in Board that computes the points that are alive in the next round and updates the points attribute accordingly. Your method should start as follows:

    def next_step(self):
        """Compute the points alive in the next round and update the
        points of the Board.
        """

Reminder of the rules

  • an alive point remains alive if it has 2 or 3 alive neighbors, otherwise it becomes dead.
  • a dead point becomes alive if it has exactly 3 neighbors, otherwise it remains dead..

Hints:

  • Do not modify Board.alive_points in-place; create a new set.
  • If s and t are sets, then s.union(t) returns their union.
  • Use is_legal() to check if points are on the board.

Test your method next_step(). In the console, you should get:

In [41]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})

In [42]: b.next_step()

In [43]: b.alive_points
Out[43]: {Point(2, 1), Point(2, 2), Point(2, 3)}

To test next_step() in the graphical representation and actually play the game, all you need to do is

  1. Delete the # from the line self.create_next_button() in create_widgets() in the class GraphicalLife (in tkview.py), and run tkview.py again to make sure this modified class is being used.
  2. Create a Board object, b, as above.
  3. Create a GraphicalLife object: g = GraphicalLife(b, 20)
  4. Do g.mainloop() to get things started. Your graphical window should now have a working “Next” button.

Try it out, and play around with some different starting configurations for the board, to see how they develop.

Afterwards, upload your file life.py:

Upload form is only available when connected

Exercise 6: Loading boards

As you might have seen in the last exercise, creating initial conditions for the board is somewhat cumbersome. To simplify this, add a function load_from_file() (outside the Board class). The behavior of load_from_file() should be as described below:

def load_from_file(filename):
    """Load and return a board configuration from file in the following format:
    - The first two lines contain a number representing the size in
        x- and y-coordinates, respectively.
    - Each of the following lines gives the coordinates of a single
        point, with the two coordinate values separated by a comma.
        Those are the points that are alive on the board.
    """

Test your functions. You can load some of the boards provided in boards.zip and see what they do. You will need to download the file (right-clicking on the link and selecting “Save link as…”), saving it in your Tutorial_8 project directory. You should see boards.zip appear in the file browser on the left-hand-side of Spyder. Now double-click on board.zip, and click “Extract” in the pop-up. You should now have a series of files in your Tutorial_8 project that contain board descriptions.

For example, you should get the following in the ipython console (though the order of the points in the set may of course be different):

In [44]: b = load_from_file('blinker.lf')

In [45]: b.alive_points
Out[45]: {Point(1, 2), Point(2, 2), Point(3, 2)}

Play around with the game and see how different input boards develop over time.

Afterwards, upload your file life.py:

Upload form is only available when connected

Exercise 7: Changing boards

Creating your own boards is still somewhat inconvenient, because you have to edit files by hand. In this exercise, we will create a graphical way of creating configurations. To this end, create a method toggle_point() that given coordinates x and y adds Point(x, y) to the set in the alive_points attribute if it is not there and deletes it from the set if it is there. Add this method to Board! It should begin as follows:

    def toggle_point(self, x, y):
        """Add Point(x, y) if it is not in alive_points, otherwise delete it
        from points.
        """

Test your method toggle_point(). You should have the following behavior:

In [46]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})

In [47]: b.toggle_point(1, 2)

In [48]: b.toggle_point(2, 1)

In [49]: b.alive_points
Out[49]: {Point(2, 1), Point(2, 2), Point(3, 2)}

When the method seems to work, you can integrate it into the graphical representation of the game by deleting the # from the line #self.canvas.bind("<Button-1>", self.toggle_point) in create_widgets() in the class GraphicalLife (in tkview.py; don’t forget to run tkview.py again afterwards). Clicking directly on the playing field of the graphical representation should now allow you to add and delete live points.

Upload your file life.py:

Upload form is only available when connected

Exercise 8: Periodicity

We say that a Board b is periodic if there is a number k such that after applying b.next_step() k times, the set b.alive_points is the same as in b. Essentially this means that after some rounds the game comes back to its initial state. Note that, since our Boards are finite, there are only finitely many possibilities for the set of alive points, so after a finite number of iterations they must cycle. However, it is not the case that they must come back to their initial state.

Write a function is_periodic() that, given a Board, decides if it is periodic (returning to state 0), and returns the index of the state to which it loops (as this always exists on a finite board). You function should start as follows:

def is_periodic(board):
    """
    Return (True, 0) if the input board is periodic, otherwise (False, i),
    where i is the smallest index of the state to which it loops
    """

Test your method is_periodic(). You should have the following behavior:

In [50]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})

In [51]: is_periodic(b)
Out[51]: (True, 0)

In [52]: b = Board(5, 5, {Point(1, 2), Point(3, 2)})

In [53]: is_periodic(b)
Out[53]: (False, 1)

To avoid infinite loops, you can set a maximum number of iterations (say 1000) for your loop.

Afterwards, upload your file life.py:

Upload form is only available when connected

Optional Exercise 9: Saving boards

Now you can create your own board configurations, but unfortunately there is no way to save them to a file yet, so you cannot exchange them with your friends. To make this possible, add a method save_to_file() to Board.

    def save_to_file(self, filename):
        """Save a board to a file. The format is that described for
        load_from_file().
        """

Now, in the console, we should be able to do this:


In [54]: b = load_from_file('blinker.lf')

In [55]: b.alive_points
Out[55]: {Point(1, 2), Point(2, 2), Point(3, 2)}

In [56]: b.save_to_file('blinker2.lf')


In [57]: b2 = load_from_file('blinker2.lf')

In [58]: b.alive_points == b2.alive_points
Out[58]: True

Afterwards, upload your file life.py:

Upload form is only available when connected

To make saving in the graphical frontend possible, delete the # from the line #self.create_save_button() in create_widgets() in the class GraphicalView. You should now have a button that allows you to save the current configuration to a file.

Optional Exercise 10: Visualizing Boards

You have now programmed a fully functional version of the Game of Life. Still, you might feel somewhat unsatisfied, because the graphical representation was given to you so you could argue that you actually did not write an essential part of the game. In this exercise, we will give you the opportunity to write your own frontend to visualize Boards.

Since writing frontends with graphical user interface libraries is a tedious process that often involves a lot of trial and error, we will set ourselves a more modest goal here: we will write a rather crude text based version, that still allows us to have a fully functional game. (Of course the really ambitious of you are still encouraged to try a version with library like for example pygame or pyqt.)

Write a class TextView with an initializer to set the board. Your class should begin as follows

class TextView:
    """A text visualization of Board instances.
    Data attributes:
    ...
    """

    def __init__(self, board):
        # Initialize the board...

Next, add a method show() that begins as follows:

    def show(self):
        """Print out a textual representation of the board."""

The method show() should

  • first print out a line of 'o',
  • then for every y value print a line starting and ending with 'o'. The other entries represent the board as follows: if the point at entry (s, t) is alive, then the tth line has a capital 'X' at position s. Otherwise, that field is the space character ' '.
  • Finally, show() prints another line of 'o'.

For example, you should have the following behavior:

In [59]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})

In [60]: t = TextView(b)

In [61]: t.show()
ooooooo
o     o
o     o
o XXX o
o     o
o     o
ooooooo

In [62]: b.alive_points = {Point(2, 1), Point(2, 2), Point(2, 3)}

In [63]: t.show()
ooooooo
o     o
o  X  o
o  X  o
o  X  o
o     o
ooooooo

Afterwards, upload your file life.py:

Upload form is only available when connected

Optional Exercise 11: Adding the game loop

We now have everything in place to complete our text based version of the Game of Life. We will put the game loop into its own class which we call LifeGame. Write that class and add an initializer that

  • initializes the attribute board with a given Board, and
  • creates a TextView for board and stores it in the attribute view.

You class should begin as follows:

class LifeGame:
    """The game loop for the text based Game of Life.
    Data attributes:
    board -- the game board
    view  -- a TextView to visualize the game
    """

    def __init__(self, board):
        # Initialize the board and create a TextView for it.

Next, add the following method to the TextView class:

    def tick(self):
        """Advance one step forward in time."""
        input('Press Enter for next step')

Finally, add a method run() to LifeGamethat runs the Game of Life for a given number of steps. In every step, show the board with the help of the view and prompt the user to continue by calling view.tick(). Your method run should begin as follows:

    def run(self, steps):
        """Run the game of life for the given number of steps.
        At every step, show the board and prompt the user to continue
        by calling tick() on the TextView.
        """

Test your game! You should get the following output:

In [64]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})

In [65]: g = LifeGame(b)

In [66]: g.run(3)
ooooooo
o     o
o     o
o XXX o
o     o
o     o
ooooooo

Press Enter for next step
ooooooo
o     o
o  X  o
o  X  o
o  X  o
o     o
ooooooo

Press Enter for next step
ooooooo
o     o
o     o
o XXX o
o     o
o     o
ooooooo

Press Enter for next step

Upload your file life.py:

Upload form is only available when connected

Optional Exercise 12: Going further

Game of Life comes in many different versions. Some of them that we encourage you to explore are the following:

  • Game of Life was originally defined for infinite grids. Implement this! Note that most parts of the program already support infinite grids. You will probably just have to change Board.is_legal() and the visualization. For the latter, you might for example remember a bounding box, i.e., a part of the grid that currently contains live points.

  • One common variation of Game of Life is played on a grid that “wraps around” at the border. That is to say that all coordinates are computed modula x_size, respectively y_size. Topologically, this means that the grid becomes a toroidal grid. You can also implement other surfaces.

  • You can also implement hexagonal B2/S34 version of Game of Life.

Upload your file life.py:

Upload form is only available when connected

Revision: Sets and hashing

In this week’s lecture, we intruduced a new Python collection data structure which we will be using : sets. You can create a set in Python by enclosing its elements in curly brackets: {1, 3, 5}, {'c', 't', 'a', 'g'}, et cetera (like the usual mathematical notation).

Set and element properties

Compared with lists, sets

Why would you use a set instead of a list? Maybe you want to force element uniqueness, but there is an even better reason: element lookup is very fast (generally O(1) instead of O(n), as you would have seen in class this week).

To make this uniqueness and fast lookup work, every element of a set needs to implement two special methods:

  1. __eq__ (an equality test), which allows us to force uniqueness in a meaningful way: after all, how can you tell if the same element is already in a set unless you can check their equality properly? Last week, we have implemented __eq__ for you. This week you will have to do so yourself, so if you don’t remember how they work, have a look at the explanation in last week’s tutorial.
  2. __hash__ (a hash method), which allows us to make lookup/access work much faster than it does in lists.

We will start doing this in Exercise 2 below, but first, let’s look a bit more closely at hashing.

Hash functions

We saw in class that Python lists are internally represented as dynamic arrays, and then when you want to search for an element (using l.index(e) or e in l, for example) this results in a slow linear search through the whole array until the element is found.

Python sets are designed to make membership testing and lookup fast. The idea is to use a hash function to choose where to store each element in the internal array (and then again to locate elements quickly). You will not control that storage strategy or the lookup algorithm explicitly, but you do have to make your objects “hashable” if you want to put them in a set.

A hash function maps objects to integer hash values. There is no unique way to define a hash value for an object; choosing a good hash function is an experimental art. The hash values of built-in object types (like ints and strings) are built-in, but you will need to define hash values for objects of some of the classes you write. You can see what they are using the built-in function hash:

In [67]: hash('hello!')
Out[67]: 4024925843313347239

In [68]: hash('HELLO.')
Out[68]: 3038809499966355249

Notice that the hash value generally has no obvious or logical connection to the object value. Also, small changes in the input to hash often produce significant change in the output:

In [3]: hash('a')
Out[3]: -4399520974720094316

In [4]: hash('b')
Out[4]: -4722603994861812535

Hashing objects with __hash__ methods

Python calls the __hash__ method of an object through the built-in hash() function: that is, hash(x) == x.__hash__() whenever x has a __hash__ method defined (otherwise Python uses a particularly dumb default based on the physical address of x in memory). Likewise, you should always use hash(x), and never call the __hash__ method directly (doing otherwise is a crime against good style).

How can you define a hash value for an object? The simplest way is to define the hash as a simple linear combination of the hash(a) for each data attribute a in the object, but you should make sure that you skip over any data attributes whose values might change over the lifetime of the object!