├── .gitignore ├── README.md ├── compile.py ├── life.py ├── pyproject.toml ├── screenshot.png └── src ├── colors.py ├── constants.py ├── conway.py ├── life.py └── timer.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.c 2 | *.pyd 3 | *.html 4 | **/__pycache__ 5 | .vscode 6 | .venv* 7 | .env* 8 | src/build 9 | dist 10 | *.zip 11 | *.json 12 | debug.log 13 | avx.txt 14 | devreqs.txt 15 | pack.bat 16 | runpack.bat 17 | trace.dat 18 | **/*.egg-info -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A simple example of Conway's Game Of Life, using Pyglet for visualization and Cython for speed. 2 | 3 | ![](screenshot.png) 4 | 5 | This version uses Cython's "pure Python" syntax, so it can be run both with and without Cython compilation. This demonstrates clearly the difference in speed between regular Python and Cython. 6 | 7 | # Setup 8 | 9 | 1. Install requirements (`pip install -r requirements.txt`). A venv is recommended. 10 | 2. Use `python compile.py` to build the extension modules. 11 | 3. Run `life.py` from the root directory to execute the demo. 12 | 13 | # Usage 14 | 15 | Click and drag within the window to move the view around. 16 | 17 | Use the `[` and `]` keys to change color palettes. 18 | 19 | Press `p` to pause and unpause; press `.` to single-step. 20 | 21 | Press `0` through `9` to alter the speed of the simulation. The maximum speed is the `FRAMERATE` variable in `conway.py`. 22 | 23 | Press `Space` to randomize the playing field. 24 | 25 | Press `Shift-1` through `Shift-9` to alter the factor for randomization. 26 | 27 | # Notes 28 | 29 | The numbers that pop up in the console during runtime are: 30 | * the average time taken by the program to compute a new generation of the playing field 31 | * time to render the results to the buffer 32 | * time to draw 33 | 34 | Th `src\life.py` file has some alternate rules for Life that can be uncommented and used in place of the existing rules. 35 | 36 | You can also edit the colors used to render the playing field by altering the values for `colors` in `src\conway.py`. 37 | 38 | # License 39 | 40 | MIT 41 | -------------------------------------------------------------------------------- /compile.py: -------------------------------------------------------------------------------- 1 | import sys, os 2 | 3 | sys.argv = ["compile.py", "build_ext", "--inplace"] 4 | 5 | from setuptools import setup 6 | from setuptools import Extension 7 | from Cython.Build import cythonize 8 | 9 | import glob, os 10 | 11 | for ff in ("*.c", "*.html"): 12 | for f in glob.glob(ff): 13 | try: 14 | os.remove(f) 15 | except FileNotFoundError: 16 | pass 17 | 18 | ext_modules = [ 19 | Extension( 20 | "life", 21 | ["life.py"], 22 | # Use this line only if you're compiling with MSVC. 23 | extra_compile_args=["/arch:AVX512", "/O2"] 24 | # extra_compile_args=["/O2"] 25 | # arch:AVX2 26 | # arch:AVX512 27 | # Omit /arch:AVX512 line if the module crashes. 28 | # Use /MD for multithread DLL (not implemented yet) 29 | ) 30 | ] 31 | 32 | os.chdir("src") 33 | 34 | setup(name="life", ext_modules=cythonize(ext_modules, annotate=True)) 35 | -------------------------------------------------------------------------------- /life.py: -------------------------------------------------------------------------------- 1 | from src import conway 2 | conway.main() -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name="conway-2023" 3 | version="0.2023" 4 | dependencies = ["pyglet"] 5 | 6 | [project.optional-dependencies] 7 | dev = ["black", "cython ~=3.0.0b1"] -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/syegulalp/conway-2022/f9442ad177ed4dee470a6993bc9c13fdbe596eda/screenshot.png -------------------------------------------------------------------------------- /src/colors.py: -------------------------------------------------------------------------------- 1 | basic = [ 2 | [0, 0, 0, 255], 3 | [0, 0, 0, 255], 4 | [255, 255, 255, 255], 5 | [255, 255, 255, 255], 6 | ] 7 | 8 | green_shades = [ 9 | [0, 31, 0, 255], 10 | [0, 0, 0, 255], 11 | [0, 127, 0, 255], 12 | [0, 255, 0, 255], 13 | ] 14 | 15 | rainbow_colors = [ 16 | [255, 0, 0, 255], 17 | [0, 0, 0, 255], 18 | [0, 255, 0, 255], 19 | [0, 0, 255, 255], 20 | ] 21 | 22 | cga1 = [ 23 | [255, 85, 85, 255], 24 | [0, 0, 0, 255], 25 | [85, 255, 255, 255], 26 | [255, 255, 255, 255], 27 | ] 28 | 29 | cga2 = [ 30 | [85, 255, 255, 255], 31 | [0, 0, 0, 255], 32 | [255, 85, 255, 255], 33 | [255, 255, 255, 255], 34 | ] 35 | 36 | cga3 = [ 37 | [255, 85, 85, 255], 38 | [0, 0, 0, 255], 39 | [85, 255, 85, 255], 40 | [255, 255, 85, 255], 41 | ] 42 | 43 | all_colors = [basic, green_shades, rainbow_colors, cga1, cga2, cga3] 44 | -------------------------------------------------------------------------------- /src/constants.py: -------------------------------------------------------------------------------- 1 | WIDTH = 300 2 | HEIGHT = 200 3 | 4 | ZOOM = 3 5 | FRAMERATE = 60 6 | FACTOR = 5 7 | -------------------------------------------------------------------------------- /src/conway.py: -------------------------------------------------------------------------------- 1 | import pyglet 2 | 3 | pyglet.options["debug_gl"] = False 4 | pyglet.options["shadow_window"] = False 5 | pyglet.options["vsync"] = True 6 | pyglet.image.Texture.default_mag_filter = pyglet.gl.GL_NEAREST 7 | 8 | from .timer import Timer 9 | from .life import Life 10 | from .constants import * 11 | from .colors import * 12 | 13 | import ctypes 14 | import array 15 | import sys 16 | 17 | rules = { 18 | "life": [[0, 0, 0, 2, 0, 0, 0, 0, 0], [-1, -1, 1, 1, -1, -1, -1, -1, -1]], 19 | "highlife": [[0, 0, 0, 2, 0, 0, 2, 0, 0], [-1, -1, 1, 1, -1, -1, -1, -1, -1]], 20 | "dotlife": [[0, 0, 0, 2, 0, 0, 2, 0, 0], [1, 0, 1, 1, -1, -1, -1, -1, -1]], 21 | "lowdeath": [ 22 | [0, 0, 0, 2, 0, 0, 2, 0, 2], 23 | [-1, -1, 1, 1, 1, -1, -1, -1, -1], 24 | ], 25 | "pedestrian": [[0, 0, 0, 2, 0, 0, 0, 0, 2], [-1, -1, 1, 1, -1, -1, -1, -1, -1]], 26 | "2x2": [[0, 0, 0, 2, 0, 0, 2, 0, 0], [-1, 1, 1, -1, -1, 1, -1, -1, -1]], 27 | "diamoeba": [[0, 0, 0, 2, 0, 2, 2, 2, 2], [-1, 1, -1, -1, -1, 1, 1, 1, 1]], 28 | "honey": [[0, 0, 0, 2, 0, 0, 0, 0, 2], [-1, -1, 1, 1, -1, -1, -1, -1, 1]], 29 | } 30 | 31 | 32 | class MyWindow(pyglet.window.Window): 33 | def __init__(self, *a, **ka): 34 | super().__init__(*a, visible=False, **ka) 35 | 36 | self.rule_descriptions = list(rules.keys()) 37 | 38 | rule_name = None 39 | 40 | try: 41 | rule_switch = sys.argv.index("-r") 42 | except ValueError: 43 | pass 44 | else: 45 | try: 46 | rule_name = sys.argv[rule_switch + 1] 47 | except IndexError: 48 | pass 49 | 50 | if rule_name not in rules: 51 | print(rule_name, "not found. Valid rules:") 52 | print(" | ".join(self.rule_descriptions)) 53 | rule_name = "life" 54 | 55 | rule_set = rules[rule_name] 56 | 57 | self.rule_name = rule_name 58 | 59 | self.colors = 0 60 | self.game_obj = Life(WIDTH, HEIGHT, all_colors[self.colors], rule_set) 61 | 62 | self.framerate = FRAMERATE 63 | self.randomization_factor = FACTOR 64 | 65 | self.set_location( 66 | self.screen.width // 2 - self.width // 2, 67 | self.screen.height // 2 - self.height // 2, 68 | ) 69 | 70 | self.batch = pyglet.graphics.Batch() 71 | self.text_batch = pyglet.graphics.Batch() 72 | self.texture = pyglet.image.Texture.create(WIDTH, HEIGHT) 73 | 74 | self.label = pyglet.text.Label( 75 | "", 76 | x=8, 77 | y=self.height - 8, 78 | anchor_x="left", 79 | anchor_y="top", 80 | multiline=True, 81 | width=self.width // 2, 82 | batch=self.text_batch, 83 | color=(255, 255, 0, 255), 84 | ) 85 | 86 | self.life = [array.array("b", b"\x00" * WIDTH * HEIGHT) for _ in range(2)] 87 | self.buffer = array.array("B", b"\x00" * WIDTH * HEIGHT * 4) 88 | 89 | self.sprites = [] 90 | for _ in range(4): 91 | sprite = pyglet.sprite.Sprite(self.texture, 0, 0, batch=self.batch) 92 | sprite.scale = ZOOM 93 | self.sprites.append(sprite) 94 | 95 | self.sprites[1].x = -WIDTH * ZOOM 96 | self.sprites[2].x = -WIDTH * ZOOM 97 | self.sprites[2].y = -HEIGHT * ZOOM 98 | self.sprites[3].y = -HEIGHT * ZOOM 99 | 100 | self.world = 0 101 | 102 | self.game_obj.randomize(self, self.randomization_factor) 103 | 104 | self.life_timer = Timer() 105 | self.render_timer = Timer() 106 | self.draw_timer = Timer() 107 | 108 | self.zoom = ZOOM 109 | 110 | pyglet.clock.schedule_interval(self.run, 1 / self.framerate) 111 | pyglet.clock.schedule_interval(self.get_avg, 1.0) 112 | 113 | self.label.text = f"Rule set: {self.rule_name}" 114 | 115 | self.running = True 116 | self.set_visible(True) 117 | 118 | def get_avg(self, *a): 119 | self.label.text = ( 120 | f"Rule set: {self.rule_name}\n" 121 | f"New generation: {self.life_timer.avg:.7f}\n" 122 | f"Display rendering time: {self.render_timer.avg:.7f}\n" 123 | f"Draw time: {self.draw_timer.avg:.7f}\n" 124 | f"Framerate: {((1/60)/self.life_timer.avg)*60:.2f}\n\n" 125 | "Click and drag in window to reposition\nTab: toggle HUD\n0-9: alter generation speed\n" 126 | "Space: randomize field\np: Pause/unpause\n[ or ]: switch color palette\n\n" 127 | f"Rules: {' | '.join(self.rule_descriptions)}" 128 | ) 129 | 130 | def on_mouse_drag(self, x, y, dx, dy, *a): 131 | for _ in self.sprites: 132 | _.x = ((_.x + dx) % (WIDTH * ZOOM * 2)) - WIDTH * ZOOM 133 | _.y = ((_.y + dy) % (HEIGHT * ZOOM * 2)) - HEIGHT * ZOOM 134 | 135 | def on_key_press(self, symbol, modifiers): 136 | print(symbol, modifiers) 137 | if symbol == 65289: 138 | self.label.visible = not self.label.visible 139 | elif 48 <= symbol <= 57: 140 | if modifiers == 1: 141 | self.randomization_factor = symbol - 48 142 | else: 143 | self.framerate = int(((symbol - 47) / 10) * FRAMERATE) 144 | if self.running: 145 | pyglet.clock.unschedule(self.run) 146 | pyglet.clock.schedule_interval(self.run, 1 / self.framerate) 147 | 148 | elif symbol in (91, 93): 149 | direction = symbol - 92 150 | self.colors = (self.colors + direction) % len(all_colors) 151 | self.game_obj.set_colors(all_colors[self.colors]) 152 | if not self.running: 153 | self.on_draw() 154 | elif symbol == 32: 155 | self.game_obj.randomize(self, self.randomization_factor) 156 | if not self.running: 157 | self.run() 158 | if self.running: 159 | if symbol == 112 or symbol == 46: 160 | self.running = not self.running 161 | pyglet.clock.unschedule(self.run) 162 | else: 163 | if symbol == 46: 164 | self.run() 165 | elif symbol == 112: 166 | self.running = not self.running 167 | pyglet.clock.schedule_interval(self.run, 1 / self.framerate) 168 | 169 | return super().on_key_press(symbol, modifiers) 170 | 171 | def run(self, *a): 172 | with self.life_timer: 173 | self.game_obj.generation(self) 174 | self.invalid = True 175 | 176 | def on_draw(self): 177 | self.invalid = False 178 | 179 | with self.render_timer: 180 | self.game_obj.render(self) 181 | self.texture.blit_into( 182 | pyglet.image.ImageData(WIDTH, HEIGHT, "RGBA", self.buffer.tobytes()), 183 | 0, 184 | 0, 185 | 0, 186 | ) 187 | with self.draw_timer: 188 | self.clear() 189 | self.batch.draw() 190 | self.text_batch.draw() 191 | 192 | 193 | def main(): 194 | w = MyWindow(int(WIDTH * ZOOM), int(HEIGHT * ZOOM)) 195 | import gc 196 | 197 | try: 198 | ctypes.windll.winmm.timeBeginPeriod(1) 199 | except: 200 | pass 201 | gc.freeze() 202 | pyglet.app.run() 203 | 204 | 205 | if __name__ == "__main__": 206 | main() 207 | -------------------------------------------------------------------------------- /src/life.py: -------------------------------------------------------------------------------- 1 | # cython: language_level=3 2 | # cython: boundscheck=False 3 | # cython: wraparound=False 4 | # cython: initializedcheck=False 5 | # cython: cdivision = True 6 | # cython: always_allow_keywords =False 7 | # cython: unraisable_tracebacks = False 8 | # cython: binding = False 9 | 10 | import cython 11 | 12 | if cython.compiled: 13 | from cython.cimports.cpython import array as arr # type: ignore 14 | from cython.cimports.libc.stdlib import rand # type: ignore 15 | from cython.cimports.cpython.mem import PyMem_Malloc, PyMem_Free # type: ignore 16 | else: 17 | import array as arr 18 | from random import random 19 | 20 | def rand(): 21 | return int(random() * 100) 22 | 23 | 24 | @cython.cfunc 25 | def ptr(arr_obj: arr.array) -> cython.p_char: 26 | array_ptr: cython.p_char = arr_obj.data.as_chars 27 | return array_ptr 28 | 29 | 30 | @cython.cclass 31 | class Life: 32 | lookupdata: cython.p_int 33 | height: cython.int 34 | width: cython.int 35 | array_size: cython.int 36 | display_size: cython.int 37 | size: cython.int 38 | colors: cython.uchar[4][4] 39 | rules: cython.int[9][2] 40 | 41 | def set_colors(self, colors: list): 42 | self.colors = colors 43 | 44 | @cython.cdivision(False) 45 | def __init__(self, width: cython.int, height: cython.int, colors: list, rules): 46 | index: cython.size_t = 0 47 | y: cython.int 48 | x: cython.int 49 | y3: cython.int 50 | x3: cython.int 51 | y4: cython.int 52 | skip: cython.int 53 | 54 | self.set_colors(colors) 55 | 56 | if cython.compiled: 57 | self.rules[0][:] = rules[0][:] 58 | self.rules[1][:] = rules[1][:] 59 | else: 60 | self.rules = rules 61 | 62 | self.height = height 63 | self.width = width 64 | self.size = height * width 65 | self.array_size = self.size * 8 66 | self.display_size = self.size * 4 67 | if cython.compiled: 68 | self.lookupdata = cython.cast( 69 | cython.p_int, PyMem_Malloc(self.array_size * cython.sizeof(cython.int)) 70 | ) 71 | else: 72 | self.lookupdata = arr.array("i", [0] * self.array_size) 73 | 74 | with cython.nogil: 75 | for y in range(0, height): 76 | for x in range(0, width): 77 | skip = 0 78 | for y3 in range(y - 1, y + 2): 79 | y3 = y3 % height 80 | y4 = y3 * width 81 | for x3 in range(x - 1, x + 2): 82 | skip += 1 83 | if skip == 5: 84 | continue 85 | x3 = x3 % width 86 | self.lookupdata[index] = y4 + x3 87 | index += 1 88 | 89 | def __dealloc__(self): 90 | PyMem_Free(self.lookupdata) 91 | 92 | def randomize(self, game, factor: cython.char): 93 | if cython.compiled: 94 | world: cython.p_char = ptr(game.life[game.world]) 95 | else: 96 | world: arr.array = game.life[game.world] 97 | 98 | x: cython.size_t 99 | 100 | for x in range(0, self.size): 101 | world[x] = rand() % factor == 1 102 | 103 | def generation(self, game): 104 | total: cython.int 105 | x: cython.int 106 | neighbor: cython.int 107 | index: cython.size_t = 0 108 | 109 | if cython.compiled: 110 | this_world: cython.p_char = ptr(game.life[game.world]) 111 | other_world: cython.p_char = ptr(game.life[not game.world]) 112 | else: 113 | this_world: arr.array = game.life[game.world] 114 | other_world: arr.array = game.life[not game.world] 115 | 116 | l = self.lookupdata 117 | position: cython.int 118 | 119 | rules = self.rules 120 | 121 | with cython.nogil: 122 | for position in range(0, self.size): 123 | total = 0 124 | for neighbor in range(0, 8): 125 | total += this_world[l[index]] > 0 126 | index += 1 127 | 128 | other_world[position] = rules[this_world[position] > 0][total] 129 | 130 | game.world = not game.world 131 | 132 | def render(self, game): 133 | if cython.compiled: 134 | world: cython.p_char = ptr(game.life[game.world]) 135 | imagebuffer: cython.p_char = ptr(game.buffer) 136 | else: 137 | world: arr.array = game.life[game.world] 138 | imagebuffer: arr.array = game.buffer 139 | 140 | display_size: cython.size_t = self.display_size 141 | world_pos: cython.size_t = 0 142 | color_byte: cython.size_t 143 | display_pos: cython.size_t 144 | world_value: cython.char 145 | wv: cython.char 146 | 147 | with cython.nogil: 148 | for display_pos in range(0, display_size, 4): 149 | world_value = world[world_pos] 150 | wv = world_value + 1 151 | for color_byte in range(0, 4): 152 | imagebuffer[display_pos + color_byte] = self.colors[wv][color_byte] 153 | world_pos += 1 154 | -------------------------------------------------------------------------------- /src/timer.py: -------------------------------------------------------------------------------- 1 | from time import perf_counter as clock 2 | 3 | 4 | class Timer: 5 | def __init__(self): 6 | self.avg = None 7 | 8 | def __enter__(self): 9 | self.start = clock() 10 | 11 | def __exit__(self, *a): 12 | self.total = clock() - self.start 13 | if self.avg: 14 | self.avg = (self.avg + self.total) / 2 15 | else: 16 | self.avg = self.total 17 | --------------------------------------------------------------------------------