2
\$\begingroup\$

This query is a part of Conway’s game of life. Currently, this program takes about 70 lines of code in Python to return the functionality of the game, which can be simplified to fewer lines of code and it ends when a keyboard interrupt occurs. I eventually want to eliminate the keyboard interrupt and improve this code with maybe a try and except function to have the game show a message stating that it is over. Any recommendations for cleaning this code up with maybe some additional functions?

Like any game, there are rules! Here's a quick overview. At the heart of this game are four rules that determine if a cell is live or dead. It all depends on how many of that cell's neighbors are alive.

  1. Births: Each dead cell adjacent to exactly three live neighbors will become live in the next generation.
  2. Death by isolation: Each live cell with one or fewer live neighbors will die in the next generation.
  3. Death by overcrowding: Each live cell with four or more live neighbors will die in the next generation.
  4. Survival: Each live cell with either two or three live neighbors will remain alive for the next generation.

Rules apply to all cells at the same time.

# Conway's Game of Life
import random
import time
import copy

WIDTH = 60
HEIGHT = 20

# Create a list of list for the cells:
nextCells = []
for x in range(WIDTH):
    column = [] # Create a new column.
    for y in range(HEIGHT):
        if random.randint(0, 1) == 0:
            column.append('#') # Add a living cell.
    else:
        column.append(' ') # Add a dead cell.
    nextCells.append(column) # nextCells is a list of column lists.

while True: # Main program loop.
    print('\n\n\n\n\n') # Separate each step with newlines.
    currentCells = copy.deepcopy(nextCells) 

# Print currentCells on the screen:
for y in range(HEIGHT):
    for x in range(WIDTH):
        print(currentCells[x][y], end='') # Print the # or space.
    print() # Print a newline at the end of the row.

# Calculate the next step's cells based on current step's cells:
for x in range(WIDTH):
    for y in range(HEIGHT):
        # Get neighboring coordinates:
        # `% WIDTH` ensures leftCoord is always between 0 and WIDTH - 1
        leftCoord  = (x - 1) % WIDTH
        rightCoord = (x + 1) % WIDTH
        aboveCoord = (y - 1) % HEIGHT
        belowCoord = (y + 1) % HEIGHT

        # Count number of living neighbors:
        numNeighbors = 0
        if currentCells[leftCoord][aboveCoord] == '#':
            numNeighbors += 1 # Top-left neighbor is alive.
        if currentCells[x][aboveCoord] == '#':
            numNeighbors += 1 # Top neighbor is alive.
        if currentCells[rightCoord][aboveCoord] == '#':
            numNeighbors += 1 # Top-right neighbor is alive.
        if currentCells[leftCoord][y] == '#':
            numNeighbors += 1 # Left neighbor is alive.
        if currentCells[rightCoord][y] == '#':
            numNeighbors += 1 # Right neighbor is alive.
        if currentCells[leftCoord][belowCoord] == '#':
            numNeighbors += 1 # Bottom-left neighbor is alive.
        if currentCells[x][belowCoord] == '#':
            numNeighbors += 1 # Bottom neighbor is alive.
        if currentCells[rightCoord][belowCoord] == '#':
            numNeighbors += 1 # Bottom-right neighbor is alive.

        # Set cell based on Conway's Game of Life rules:
        if currentCells[x][y] == '#' and (numNeighbors == 2 or
numNeighbors == 3):  
            # Living cells with 2 or 3 neighbors stay alive:
            nextCells[x][y] = '#'
        elif currentCells[x][y] == ' ' and numNeighbors == 3:
            # Dead cells with 3 neighbors become alive:
            nextCells[x][y] = '#'
        else:
            # Everything else dies or stays dead:
            nextCells[x][y] = ' '
time.sleep(1) # Add a 1-second pause to reduce flickering.
\$\endgroup\$
3
  • 1
    \$\begingroup\$ 'Eliminate the keyboard interrupt' and replace it with what? \$\endgroup\$ Nov 28 '21 at 15:39
  • \$\begingroup\$ Please try a 3by3 board primed with a "blinker" (3 cells alive: either middle row or column): Does it blink? \$\endgroup\$
    – greybeard
    Nov 28 '21 at 16:38
  • \$\begingroup\$ I'm tempted to make a cell have integer values, and 1 means alive, 0 means dead. This would make counting living neighbors clearer and possibly more compact by eliminating the need for if statements and just summing the neighboring cells. \$\endgroup\$ Nov 28 '21 at 17:06
3
\$\begingroup\$

Your code works and is readable, which is nice. It also mostly follows PEP8 style conventions, which is nice, although this can be improved, as it was already mentioned in the other answer.

Of course, there is always room for improvement.

Code structure

What your code lacks is structure: everything happens at the top level, with no use of functions or classes.

This is bad, as it makes the code harder to read and follow along, less maintainable and less reusable.

Breaking the code into functions allows you to focus on each aspect of the code independently. Consider the following pseudocode:

initialize()

while True:
    display_cells()
    update_state()
    wait()

In this case, the main loop is very simple and easy to follow. Now, if you want to work on how to display the cells, you can easily go to the relevant function's definition and work on just that. If you want to try another way to display, for example with a graphic display instead of characters on console, you can define another function and replace just one line in the main loop to try it out.

It also shows that it needs a way to keep track of the current state of the cell grid.

In your code, you use a global variable called nextCells. Global variables are considered bad practice for a number of good reasons, mainly because it makes it hard to keep track where in the code they are acted upon, and thus what it's value is supposed to be at a given point in the code, especially once you break the code into multiple functions.

One way to fix that is to pass the variable as arguments to functions:

cells = initialize()
while True:
    display_cells(cells)
    cells = update_state(cells)
    wait()

You could argue that cells is still technically a global variable, as it is defined at the root level of the code. One way to deal with that is to encapsulate the game state into a class. The class should hold the game state, provide ways to act upon it and to access information about it.

Implementing a class, the code now looks something like this:

class GameOfLife:
    def __init__(self):
        pass
    def update_state(self):
        pass
    def print_cells(self)
        pass
    def run(self)
        pass


game = GameOfLife()
game.run()

Now there are just a few more things left to address.

First is documentation: while the code is simple and mostly self-documenting, it is good practice to document the code using docstrings and comments, if necessary.

Docstrings should describe the purpose of classes and functions, and how to use them. Should the class/function be used elsewhere, they can be accessed with the help() builtin function of Python, and allow users to read on how to use your work without reading actual code.

Comments should provide why things are done a certain way, if it is unclear. If the code is made using descriptive names and logical units, there should be little need for comments.

Finally is reusability. Now that the game of life is encapsulated into a convenient object, it can be useful to import it from another script and use it. However, as is, the last 2 lines would execute and an instance of the game would run, which is not desirable in this case.

To allow imports and running the script, you can put the code inside a "main guard".

My take on the problem is:

import random
from time import sleep


class Life:
    '''
    An implementation of Conway's game of life on a finite and wrapping grid
    '''
    
    LIVE_CELL = '#'
    DEAD_CELL = ' '
    DELAY = 1

    def __init__(self, width=60, height=20):
        self.width = width
        self.height = height
        self.cells = [[random.choice([True, False]) for _ in range(self.width)] for _ in range(self.height)]

    def next_generation(self):
        '''
        Updates the cell grid according to Conway's rules
        '''
        next_cells = [[False for _ in range(self.width)] for _ in range(self.height)]
        for j in range(self.width):
            for i in range(self.height):
                neighbor_count = self._count_live_neighbors(i, j)
                if ((self.cells[i][j] and neighbor_count in (2, 3))
                    or (not self.cells[i][j] and neighbor_count == 3)):
                    next_cells[i][j] = True
        self.cells = next_cells

    def _count_live_neighbors(self, i, j):
        '''
        Counts the number of live neighbor of a cell, given the cell indices on the grid
        '''
        count = 0
        for di, dj in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
            if self.cells[(i + di) % self.height][(j + dj) % self.width]:
                count += 1
        return count

    def print_cells(self):
        '''
        Print the current state of the cell grid on the console
        '''
        print('\n\n\n\n\n')
        for row in self.cells:
            chars = [self.LIVE_CELL if c else self.DEAD_CELL for c in row]
            print(''.join(chars))

    def run(self):
        '''
        Run the game of life until a keyboard interrupt is raise (by pressing ctrl+c)
        '''
        try:
            while(True):
                self.print_cells()
                self.next_generation()
                sleep(self.DELAY)
        except KeyboardInterrupt:
            pass


if __name__ == '__main__':
    life = Life()
    life.run()

Other improvements

As you can see, I also changed some of the logic, so I'll explain my reasoning.

Using an array of booleans for the cell grid

True represent live cells, False dead ones. This is to improve reusability, leaving the display logic decide how to display dead or alive cells. It also simplifies some of the logic.

Using a loop to iterate over neighbors

Less copy-and-pasted code makes it less error-prone and more maintainable, while still being easy to understand in this case.

Using list comprehensions

Python one-liners can sometimes get hard to read, but list comprehensions to initialize lists is very useful and muck more efficient than iteratively appending values.

In simple cases like that, it is still quite readable.

Going further

If you're interested in going further as an exercise, I can think of a few improvements that can be done to the code:

  • Implement other ways to initialize the game, for example by passing a cell grid to the constructor (easy), or by parsing a file (a bit harder). This would allow to try out some patterns.
  • The canonical game of life as described by John Conway is carried out on an infinite grid. While this is impossible in practice, it can be approximated by keeping track of the game state for some distance outside of what is actually displayed (medium)
  • Displaying on console is not very good, as the displayed cells do not look very good, are not square, and are quite limited in number, while the console flickers. A graphical display would be more suited, but also a lot harder to implement.
  • Updating the game state can be heavily optimized. It's fine for a small number of cells (and as such for console display), but if you eventually move to a large grid, this solution is probably not good enough. Possible improvements I can think of:
    • As of now, each time the grid state is updated, a new list is allocated, then the old one is disposed of by the garbage collector. Both of these actions are slow. A solution would be to keep 2 lists in memory, and point to either one for the current generation and the next generation.
    • Calculating the state of the next generation is easily parallelisable, as each cell is independent from every other one.
\$\endgroup\$
1
  • \$\begingroup\$ My take ack. I'd go for next_cells[i][j] = 3 == neighbor_count or 2 == neighbor_count and cells[i][j]. \$\endgroup\$
    – greybeard
    Nov 30 '21 at 13:18
2
\$\begingroup\$

Indentation

Please fix your code indentation, as it is now it's painful to figure out what's supposed to be happening. The comments provide the corect suggestions to make it easy for you as well.


CONSTANTS

The characters for living and dead cells should be module-level constants. Makes them easily changeable and the code more readable:

LIVING_CELL = '#'
DEAD_CELL = ' '
CELLS = (LIVING_CELL, DEAD_CELL)

variable_names

PEP8 states that variable and function names should follow snake_case.

nextCells -> next_cells


List creation

List comprehensions are often times preferrable to manual list comprehensions. Consider this improved creation of next_cells:

import random

next_cells = []

for _ in range(WIDTH):
    column = [random.choice(CELLS) for _ in range(HEIGHT)]
    next_cells.append(column)

You might even go a step further and go for a nested comprehension:

next_cells = [[random.choice(CELLS) for _ in range(HEIGHT)] for _ in range(WIDTH)]

I'd say this one-liner is still pretty readable, probably even more so than the first suggestion, therefore I recommend it.

random.choice is a better choice for expressing your intention in this snippet.

Naming iteration variables _ is a convention to signal that the variable value is never actually used.


Main logic

You're doing way too much manual checking when counting the neighbors. You should not manually construct all possible combinations of coordinates. Instead, think about the underlying logic and how to translate that into more concise code. I'm sure there are also many resources available (probably on this site alone) for proper checking / counting neighbors for a given coordinate.

Please also note that the original game rules are designed for a board of infinite size, as far as I know there are no "official" rules for boards of limited sizes.


print('\n\n\n\n\n') is better expressed as print('\n' * 5).

\$\endgroup\$
3
  • \$\begingroup\$ My whole goal also is to get the coordinate combinations into more concise code. However, I'm stuck on the thought process and came here for tips. \$\endgroup\$
    – Brando
    Nov 28 '21 at 17:25
  • \$\begingroup\$ Would your logic for list creation work since there has to be both an x and y coordinate on the board? Also, using your nested approach, the variable "column" and "CELLS" is not defined, so what direction would you take for correcting that when simplifying those lines in particular? I added the changes and not the output returns trueFalse instead of random #'s \$\endgroup\$
    – Brando
    Nov 28 '21 at 17:55
  • 2
    \$\begingroup\$ I've added a definition for constant CELLS. I did not intend to rewrite your code for you, I've merely pointed out some useful concepts that you should understand and then try to implement yourself. The code snippets can of course be used for inspiration / guidance, but they're not necessarily intended to be copy-and-pasteable. However, I've locally implemented the proposed changes and the code seems to be working fine, I cannot reporduce the errors you're describing. \$\endgroup\$ Nov 29 '21 at 10:08

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.