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)
1 Answer 1
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 import
ed 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:
- You probably need a function for deleting a
cell
atx,y
from bothcells
andmatrix
to keep things in sync. - 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.
Explore related questions
See similar questions with these tags.