Tutorial 8: Game of Life
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:
- 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 alive neighbors, otherwise it remains dead.
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
:
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 Point
s 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
Point
s 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 Point
s 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
Point
s 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
Point
s 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
:
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]: a = Point(1, 2)
In [20]: s = {a}
In [21]: s
Out[21]: {Point(1, 2)}
In [22]: a.x += 1
In [23]: s
Out[23]: {Point(2, 2)}
In [24]: Point(2, 2) in s
Out[25]: False
Try to understand what is problematic in the example above!
Introducing and showing the Board
Having a data structure for individual Point
s, 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 [26]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})
In [27]: g = GraphicalLife(b, 20)
In [28]: 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 [29]: p = Point(2, 2)
In [30]: p.get_neighbors()
Out[30]:
{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 [31]: p = Point(0, 0)
In [32]: p.get_neighbors()
Out[32]:
{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 Point
s 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 [33]: b = Board(5, 5, set())
In [34]: b.is_legal(Point(2, 2))
Out[34]: True
In [35]: b.is_legal(Point(0, 0))
Out[35]: True
In [36]: b.is_legal(Point(0, 5))
Out[36]: False
In [37]: b.is_legal(Point(-1, 0))
Out[38]: False
Afterwards, upload your file life.py
:
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 [39]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})
In [40]: b.number_live_neighbors(Point(2, 2))
Out[40]: 2
In [41]: b.number_live_neighbors(Point(1, 2))
Out[41]: 1
In [42]: b.number_live_neighbors(Point(0, 0))
Out[42]: 0
Afterwards, upload your file life.py
:
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
andt
are sets, thens.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 [43]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})
In [44]: b.next_step()
In [45]: b.alive_points
Out[45]: {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
- Delete the
#
from the lineself.create_next_button()
increate_widgets()
in the classGraphicalLife
(intkview.py
), and runtkview.py
again to make sure this modified class is being used. - Create a
Board
object,b
, as above. - Create a
GraphicalLife
object:g = GraphicalLife(b, 20)
- 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
:
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 [46]: b = load_from_file('blinker.lf')
In [47]: b.alive_points
Out[47]: {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
:
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 [48]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})
In [49]: b.toggle_point(1, 2)
In [50]: b.toggle_point(2, 1)
In [51]: b.alive_points
Out[51]: {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
:
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 [52]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})
In [53]: is_periodic(b)
Out[53]: (True, 0)
In [54]: b = Board(5, 5, {Point(1, 2), Point(3, 2)})
In [55]: is_periodic(b)
Out[55]: (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
:
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 [56]: b = load_from_file('blinker.lf')
In [57]: b.alive_points
Out[57]: {Point(1, 2), Point(2, 2), Point(3, 2)}
In [58]: b.save_to_file('blinker2.lf')
In [59]: b2 = load_from_file('blinker2.lf')
In [60]: b.alive_points == b2.alive_points
Out[60]: True
Afterwards, upload your file life.py
:
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
Board
s.
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 thet
th line has a capital'X'
at positions
. Otherwise, that field is the space character' '
. - Finally,
show()
prints another line of'o'
.
For example, you should have the following behavior:
In [61]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})
In [62]: t = TextView(b)
In [63]: t.show()
ooooooo
o o
o o
o XXX o
o o
o o
ooooooo
In [64]: b.alive_points = {Point(2, 1), Point(2, 2), Point(2, 3)}
In [65]: t.show()
ooooooo
o o
o X o
o X o
o X o
o o
ooooooo
Afterwards, upload your file life.py
:
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 givenBoard
, and - creates a
TextView
forboard
and stores it in the attributeview
.
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 LifeGame
that
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 [66]: b = Board(5, 5, {Point(1, 2), Point(2, 2), Point(3, 2)})
In [67]: g = LifeGame(b)
In [68]: 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
:
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
, respectivelyy_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
:
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 list
s, set
s
- are unordered:
{1, 2, 3, 4}
and{3, 1, 2, 4}
are the same set - contain each element only once:
{'b', 'a', 'n', 'a', 'n', 'a'}
and{'b', 'a', 'n'}
are the same set
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:
__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.__hash__
(a hash method), which allows us to make lookup/access work much faster than it does inlist
s.
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 int
s and
str
ings) 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 [69]: hash('hello!')
Out[69]: 4024925843313347239
In [70]: hash('HELLO.')
Out[70]: 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!