2
\$\begingroup\$

I recently created game about life in python. First version of code was console, but recently I rewrote this game in pygame. I decied upgrade game and was added age of cells. But now I can see that my game when I setting up size of array more than 25*25 cells starts to lag. I think that problem can be in method "add_life_to_array" and increase array "cells" to value 250 and more values. Also I want to understood how I can update this program in architecture level, if it is possible. I try to use JIT compiling, but it does not help me(maybe because I very bad underrstand how it work and often get errors). Github of this project

Code of game:

import random
from collections import Counter
import pygame as p
from pygame.locals import *
import sys

# for compilation to exe enter: pyinstaller --noconsole game_of_life.py

class LifeGame:
    def __init__(self):
        """initialization variables"""
        self.width = 25
        self.height = 25
        # more - worse for cells
        self.death_level = 3
        # less - better for cells
        self.birth_level = 15
        # max age for cell
        self.max_age = 100
        # how many lives will be spawn on start
        self.start_spawn = 1
        self.cells = []
        self.matrix = []
        self.life = 1
        self.null = 0
        self.iteration = 0
        self.matrix = [[0 for _ in range(self.width)] for _ in range(self.height)]

    def draw(self):
        """this method draw simulation"""
        # fill layers
        root.fill(BLACK)
        capture.fill(WHITE)
        description.fill(WHITE)
        # width and height of cell
        width = (root.get_width() // self.width)
        height = (root.get_height() // self.height)
        life_cells = 0
        empty_cells = 0

        for y in range(0, self.height):
            for x in range(0, self.width):
                if self.matrix[y][x] == self.life:
                    p.draw.rect(root, RED, [width * x, height * y, width, height])
                elif self.matrix[y][x] == self.null:
                    p.draw.rect(root, WHITE, [width * x, height * y, width, height])
                else:
                    p.draw.rect(root, BLACK, [width * x, height * y, width, height])

        # draw lines
        p.draw.line(root, BLACK, (0, 0), (root.get_width(), 0))
        p.draw.line(root, BLACK, (0, root.get_height()-1), (root.get_width(), root.get_height()-1))

        # count cells
        cnt = Counter()
        for row in self.matrix:
            cnt.update(row)
        life_cells = cnt[1]
        empty_cells = cnt[1]

        # texts
        if empty_cells != self.null:
            txt_percent_of_life = font.render(str(round((life_cells / 100) * (100 / ((self.width * self.height) / 100)), 2))+"% of life", True, BLACK, WHITE)
        else:
            txt_percent_of_life = font.render("100% of life", True, BLACK, WHITE)
        txt_life_cells = medium_font.render("Count of cells: {}/{}".format(life_cells, self.width * self.height), True, BLACK, WHITE)
        txt_fps = small_font.render("FPS: {}".format(round(clock.get_fps(), 1)), True, BLACK, WHITE)
        txt_average_age = medium_font.render("Average age: {}/{}".format(round(self.count_average_age(), 2), self.max_age), True, BLACK, WHITE)
        txt_year = medium_font.render("Year: {}".format(self.iteration), True, BLACK, WHITE)
        pos = txt_percent_of_life.get_rect(center=(capture.get_width()//2, capture.get_height()//2))
        # capture
        capture.blit(txt_percent_of_life, pos)
        # description
        description.blit(txt_life_cells, (5, 5))
        description.blit(txt_fps, (430, 130))
        description.blit(txt_average_age, (5, 35))
        description.blit(txt_year, (5, 65))
        # display
        display.blit(capture, (0, 0))
        display.blit(description, (0, 550))
        display.blit(root, (0, 50))

    def spawn_life(self):
        """this method spawn life by random coordinates by given count of times"""
        rand_coords = []
        counter = 0
        for _ in range(lg.start_spawn):
            rand_coords.append([])
            rand_coords[counter].append(random.randint(0, self.width-1))
            rand_coords[counter].append(random.randint(0, self.height-1))
            counter += 1

        for i in range(len(rand_coords)):
            self.matrix[rand_coords[i][1]][rand_coords[i][0]] = self.life
        return rand_coords


    def get_available_coords(self, x, y):
        """this method return available count of coord for given coordinate"""
        available_coords = [[x-1, y], [x+1, y], [x, y+1], [x, y-1]]
        coords = []
        for i in range(len(available_coords)):
            if available_coords[i][0] <= self.width-1 and available_coords[i][0] >= 0\
                and available_coords[i][1] <= self.height-1 and available_coords[i][1] >= 0\
                and self.matrix[available_coords[i][1]][available_coords[i][0]] == self.null:
                    coords.append([available_coords[i][0], available_coords[i][1]])
        return coords

    def count_average_age(self):
        """return average age for all lifes"""
        average_age = 0
        counter = 1
        for cell in self.cells:
            counter += 1
            average_age += self.iteration-cell[2]
        return average_age / counter


    def add_life_to_matrix(self, coords):
        """this method add new coords to matrix and cells"""
        cells_arr = []
        for x, y in coords:
            chance = random.randint(0, self.birth_level)
            if chance == 0:
                self.matrix[y][x] = self.life
                if [x, y] not in cells_arr:
                    cells_arr.append([x, y])
                    self.cells.append([x, y, self.iteration])


    def del_life(self):
        """this method randomly delete life"""
        for _ in range(lg.death_level):
            del_x = random.randint(0, lg.width-1)
            del_y = random.randint(0, lg.height-1)
            self.matrix[del_y][del_x] = self.null

    def del_dead_cells(self):
        """this method delete cells value which in matrix equal '0'"""
        for y in range(self.height):
            for x in range(self.width):
                if self.matrix[y][x] == self.null:
                    for i in range(len(self.cells)):
                        if self.cells[i][0] == x and self.cells[i][1] == y:
                            del self.cells[i]
                            break
        for _ in range(len(self.cells)):
            for i in range(len(self.cells)):
                if self.iteration-self.cells[i][2] >= self.max_age:
                    chance_of_die = random.randint(0, 4)
                    if chance_of_die == 0:
                        del self.cells[i]
                        break
                elif self.iteration-self.cells[i][2] >= self.max_age//2:
                    chance_of_die = random.randint(0, 16)
                    if chance_of_die == 0:
                        del self.cells[i]
                        break
                elif self.iteration-self.cells[i][2] >= self.max_age//4:
                    chance_of_die = random.randint(0, 256)
                    if chance_of_die == 0:
                        del self.cells[i]
                        break
                elif self.iteration-self.cells[i][2] >= self.max_age//8:
                    chance_of_die = random.randint(0, 1024)
                    if chance_of_die == 0:
                        del self.cells[i]
                        break


lg = LifeGame()

lg.spawn_life()
p.init()
# RGB
BLACK = (0, 0, 0)
WHITE = (255, 255, 255)
RED = (255, 0, 0)
GREEN = (0, 255, 0)
BLUE = (0, 0, 255)
font = p.font.SysFont('lucida console', 50)
medium_font = p.font.SysFont('lucida console', 25)
small_font = p.font.SysFont('lucida console', 12)
# create window
display = p.display.set_mode((500, 700))
root = p.Surface((500, 500))
capture = p.Surface((500, 50))
description = p.Surface((500, 200))
display.fill(WHITE)
p.display.set_caption("Game of life")
FPS = 10
clock = p.time.Clock()

while True:
    coords = []
    lg.draw()

    for y in range(lg.height):
        for x in range(lg.width):
            if lg.matrix[y][x] == lg.life:
                coords.append(lg.get_available_coords(x, y))
    coords = sum(coords, [])
    lg.del_life()
    lg.iteration += 1
    lg.add_life_to_matrix(coords)
    lg.del_dead_cells()

    # for exit
    for i in p.event.get():
        if i.type == QUIT:
            sys.exit(1)
    p.display.update()
    clock.tick(FPS)
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

Bug

Your life reporting is incorrect

        life_cells = cnt[1]
        empty_cells = cnt[1]

Presumably you meant cnt[1], cnt[0]

Encapsulation

Your class depends on external variables:

width = (root.get_width() // self.width)

Which means that if I imported your code and tried to make a LifeGame it would break immediately, or its behaviour would depend on whatever root was at the time and whether it had a get_width method. These things should probably be passed in to the functions where they're used directly or set up in the __init__ method.

__init__

Speaking of which, you should be using your init method more effectively. __init__ can take arguments which are passed when you call the constructor of an object. Let's take a look at your __init__

class LifeGame:
    def __init__(self, size=(25, 25), display_size=(25, 25),
                start_spawn=1, birth_level=15, death_level=3, 
                max_age=100):
        """initialization variables"""
        self.width, self.height = size
        ## v See last section
        self.display_width = (self.width // display_size[0], 
                              self.height // display_size[1])
        ## or we could take in the pygame object
        # more - worse for cells
        self.death_level = death_level
        # less - better for cells
        self.birth_level = birth_level
        # max age for cell
        self.max_age = max_age
        # how many lives will be spawn on start
        self.start_spawn = start_spawn
        self.cells = []
        # self.matrix = [] ## Initialised later
        # self.life = 1 ## Can be computed
        # self.null = 0 ## Can be computed
        self.iteration = 0
        self.matrix = [[0 for _ in range(self.width)] for _ in range(self.height)]

Looping and unpacking

You still have many places in your code where you are looping over range(len()), these could likely all be simplified as in:

        coords = []
        for i in range(len(available_coords)):
            if available_coords[i][0] <= self.width-1 and available_coords[i][0] >= 0\
                and available_coords[i][1] <= self.height-1 and available_coords[i][1] >= 0\
                and self.matrix[available_coords[i][1]][available_coords[i][0]] == self.null:
                    coords.append([available_coords[i][0], available_coords[i][1]])

becomes

        coords = []
        for x, y in available_coords:
            if x <= self.width-1 and x >= 0\
                and y <= self.height-1 and y >= 0\
                and self.matrix[y][x] == self.null:
                    coords.append([x, y])

We could go a step further and use a list comprehension to build this and use

coords = [(x, y) for x, y in available_coords
          if 0 <= x < self.height and
             0 <= y < self.width and
             self.matrix[y][x] == self.null]

Which seems to sum up what you want rather concisely.

Functions

del_dead_cells is a very complicated function. It seems to loop through multiple times deleting cells (restarting each time it encounters a dead cell). It looks like it could be greatly simplified let's start refactoring. First, we need to work out what it's actually doing:

for y in range(self.height):
   for x in range(self.width):
       if self.matrix[y][x] == self.null:
           for i in range(len(self.cells)):
               if self.cells[i][0] == x and self.cells[i][1] == y:
                   del self.cells[i]
...
if self.iteration-self.cells[i][2] >= self.max_age:
   chance_of_die = random.randint(0, 4)
elif self.iteration-self.cells[i][2] >= self.max_age//2:
    chance_of_die = random.randint(0, 16)
elif self.iteration-self.cells[i][2] >= self.max_age//4:
    chance_of_die = random.randint(0, 256)
...

So, we're filtering the cells which are null in matrix, but still in cells and then we're randomly deleting others based on their age (but not setting them in matrix.

This tells me two things:

  1. You probably need a function for deleting a cell at x,y from both cells and matrix to keep things in sync.
  2. This function is doing two jobs and we need to update the docstring as such.

But let's just focus on refactoring. For the first part, we're checking where cells[i][0] and cells[i][1] are null in matrix, so instead of scanning matrix, let's scan cells. Now cells (from investigation, would be more useful to be documented) seems to be a list of [x, y, age], so let's use that and our first chunk becomes:

for i, x, y, _ in enumerate(self.cells): # Don't need age
    if self.matrix[y][x] == self.null:
        del self.cells[i] 

Which looks much simpler, but oops! We're modifying the loop array in the loop which is bad. Thankfully, we can use a filter through a list comprehension which makes things even simpler.

self.cells = [cell for cell in self.cells
              if self.matrix[cell[1]][cell[0]] != self.null]

Alternatively we could do

self.cells = [[x, y, age] for x, y, age in self.cells
              if self.matrix[y][x] != self.null]

or (more functional, less Pythonic)

self.cells = filter(lambda cell: self.matrix[cell[1]][cell[0]] != self.null,
                    self.cells)

Now comes the second chunk. Here, we're checking what era of max_age we're in, and thus how likely a cell is to "die". Depending on the age of the cell, we have:

0 < x < age/8     -> 0 # NB randint is inclusive
age/8 < x < age/4 -> 1/1025
age/4 < x < age/2 -> 1/257
age/2 < x < age   -> 1/17
age < x           -> 1/5

It currently loops max(N^2) times checking each cell ~N/2 times which is a little excessive for something which could be O(N) checking each cell once and once only.

Here, I would suggest using a categorising function.

def survives(self, age):
   if x < self.max_age // 8:
       return True
   if x < self.max_age // 4:
       return random.randint(0, 1024) == 0
   if x < self.max_age // 2:
       return random.randint(0, 256) == 0
   ...

or

AGES = tuple(self.max_age // i for i in (8, 4, 2, 1))
AGE_PERC = (0, 1/1024, 1/256, 1/16, 1/4) # Make these floats as %ages
def survives(self, age):
   for age_check, chance_to_die in zip(AGES, AGE_PERC):
       if age < age_check:
           return random.random() > chance_to_die
   return random.random() > chance_to_die[-1]

This give us:

self.cells = [cell for cell in self.cells if self.survives(cell[2])]

With this we can merge the checks into one, and our function might become:

def del_dead_cells(self):
    self.cells = [[x, y, age] for x, y, age in self.cells
                  if self.matrix[y][x] != self.null and self.survives(age)]

Efficiency

When it comes to drawing the board, you may want to look into not drawing the entire thing over again, but only updating the ones which have changed. This could easily by introducing a function which handles the resurrecting/killing of a cell, updating cells, matrix, drawing the appropriate colours to p, updating the life_cells/empty_cells counts and much more.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

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