Make it work, then make it beautiful, then if you really, really have to,
make it fast. 90 percent of the time, if you make it beautiful,
it will already be fast. So really, just make it beautiful!
Joe Armstrong, designer of the Erlang programming language
Introduction
In this tutorial, we will continue to develop our snake game.
We will add almost no new feature in this session ; instead,
we will do some refactoring , i.e., restructure our existing code
using some “best practices” that will (hopefully!) make it easier to
maintain and extend.
Here is a reminder of the current project state:
The code so far import pyxel
pyxel.init( 30 , 30 , fps = 10 )
snake_geometry = [
[ 10 , 15 ],
[ 11 , 15 ],
[ 12 , 15 ],
]
snake_direction = [ 1 , 0 ]
rocks = []
for i in range ( 30 ):
for j in range ( 30 ):
if (i + j) % 5 == 0 and (i - j) % 11 == 0 :
rocks.append([i, j])
def spawn_new_fruit ():
global fruit
while True :
fruit = [pyxel.rndi( 0 , 29 ), pyxel.rndi( 0 , 29 )]
if fruit not in snake_geometry and fruit not in rocks:
break
spawn_new_fruit()
arrow_keys = [
pyxel. KEY_UP ,
pyxel. KEY_DOWN ,
pyxel. KEY_LEFT ,
pyxel. KEY_RIGHT
]
def update ():
global snake_geometry, snake_direction
if pyxel.btnp(pyxel. KEY_Q ):
pyxel.quit()
arrow_keys_pressed = []
for key in arrow_keys:
if pyxel.btnp(key):
arrow_keys_pressed.append(key)
for key in arrow_keys_pressed:
if key == pyxel. KEY_UP :
snake_direction = [ 0 , - 1 ]
elif key == pyxel. KEY_DOWN :
snake_direction = [ 0 , 1 ]
elif key == pyxel. KEY_LEFT :
snake_direction = [ - 1 , 0 ]
elif key == pyxel. KEY_RIGHT :
snake_direction = [ 1 , 0 ]
snake_head = snake_geometry[ - 1 ]
new_snake_head = [
snake_head[ 0 ] + snake_direction[ 0 ],
snake_head[ 1 ] + snake_direction[ 1 ],
]
if (
new_snake_head in snake_geometry
or new_snake_head in rocks
or (
new_snake_head[ 0 ] < 0
or new_snake_head[ 0 ] > 29
or new_snake_head[ 1 ] < 0
or new_snake_head[ 1 ] > 29
)
):
snake_geometry = snake_geometry[ 1 : - 1 ] + [snake_head]
elif new_snake_head == fruit:
snake_geometry = snake_geometry + [new_snake_head]
spawn_new_fruit()
else :
snake_geometry = snake_geometry[ 1 :] + [new_snake_head]
def draw ():
pyxel.cls( 7 )
pyxel.pset(fruit[ 0 ], fruit[ 1 ], 8 )
for x, y in rocks:
pyxel.pset(x, y, 0 )
for x, y in snake_geometry[: - 1 ]:
pyxel.pset(x, y, 3 )
snake_head = snake_geometry[ - 1 ]
pyxel.pset(snake_head[ 0 ], snake_head[ 1 ], 11 )
pyxel.run(update, draw)
No More Magic
Solution import pyxel
# Geometry
WIDTH = 30
HEIGHT = 30
# Frame rate
FPS = 10
# Colors
BLACK = 0
WHITE = 7
PINK = 8
DARK_GREEN = 3
LIGHT_GREEN = 11
# Directions
UP = [ 0 , - 1 ]
RIGHT = [ 1 , 0 ]
DOWN = [ 0 , 1 ]
LEFT = [ - 1 , 0 ]
ARROW_KEYS = [
pyxel. KEY_UP ,
pyxel. KEY_DOWN ,
pyxel. KEY_LEFT ,
pyxel. KEY_RIGHT
]
pyxel.init( WIDTH , HEIGHT , fps = FPS )
snake_geometry = [
[ 10 , 15 ],
[ 11 , 15 ],
[ 12 , 15 ],
]
snake_direction = RIGHT
rocks = []
for i in range ( WIDTH ):
for j in range ( HEIGHT ):
if (i + j) % 5 == 0 and (i - j) % 11 == 0 :
rocks.append([i, j])
def spawn_new_fruit ():
global fruit
while True :
fruit = [pyxel.rndi( 0 , WIDTH - 1 ), pyxel.rndi( 0 , HEIGHT - 1 )]
if fruit not in snake_geometry and fruit not in rocks:
break
spawn_new_fruit()
def update ():
global snake_geometry, snake_direction
if pyxel.btnp(pyxel. KEY_Q ):
pyxel.quit()
arrow_keys_pressed = []
for key in ARROW_KEYS :
if pyxel.btnp(key):
arrow_keys_pressed.append(key)
for key in arrow_keys_pressed:
if key == pyxel. KEY_UP :
snake_direction = UP
elif key == pyxel. KEY_DOWN :
snake_direction = DOWN
elif key == pyxel. KEY_LEFT :
snake_direction = LEFT
elif key == pyxel. KEY_RIGHT :
snake_direction = RIGHT
snake_head = snake_geometry[ - 1 ]
new_snake_head = [
snake_head[ 0 ] + snake_direction[ 0 ],
snake_head[ 1 ] + snake_direction[ 1 ],
]
if (
new_snake_head in snake_geometry
or new_snake_head in rocks
or (
new_snake_head[ 0 ] < 0
or new_snake_head[ 0 ] > WIDTH - 1
or new_snake_head[ 1 ] < 0
or new_snake_head[ 1 ] > HEIGHT - 1
)
):
snake_geometry = snake_geometry[ 1 : - 1 ] + [snake_head]
elif new_snake_head == fruit:
snake_geometry = snake_geometry + [new_snake_head]
spawn_new_fruit()
else :
snake_geometry = snake_geometry[ 1 :] + [new_snake_head]
def draw ():
pyxel.cls( WHITE )
pyxel.pset(fruit[ 0 ], fruit[ 1 ], PINK )
for x, y in rocks:
pyxel.pset(x, y, BLACK )
for x, y in snake_geometry[: - 1 ]:
pyxel.pset(x, y, DARK_GREEN )
snake_head = snake_geometry[ - 1 ]
pyxel.pset(snake_head[ 0 ], snake_head[ 1 ], LIGHT_GREEN )
pyxel.run(update, draw)
Modules
Solution constants.py
:
import pyxel
# Geometry
WIDTH = 30
HEIGHT = 30
# Frame rate
FPS = 10
# Colors
BLACK = 0
WHITE = 7
PINK = 8
DARK_GREEN = 3
LIGHT_GREEN = 11
# Directions
UP = [ 0 , - 1 ]
RIGHT = [ 1 , 0 ]
DOWN = [ 0 , 1 ]
LEFT = [ - 1 , 0 ]
ARROW_KEYS = [
pyxel. KEY_UP ,
pyxel. KEY_DOWN ,
pyxel. KEY_LEFT ,
pyxel. KEY_RIGHT
]
snake.py
:
import pyxel
from constants import *
pyxel.init( WIDTH , HEIGHT , fps = FPS )
snake_geometry = [
[ 10 , 15 ],
[ 11 , 15 ],
[ 12 , 15 ],
]
snake_direction = RIGHT
rocks = []
for i in range ( WIDTH ):
for j in range ( HEIGHT ):
if (i + j) % 5 == 0 and (i - j) % 11 == 0 :
rocks.append([i, j])
def spawn_new_fruit ():
global fruit
while True :
fruit = [pyxel.rndi( 0 , WIDTH - 1 ), pyxel.rndi( 0 , HEIGHT - 1 )]
if fruit not in snake_geometry and fruit not in rocks:
break
spawn_new_fruit()
def update ():
global snake_geometry, snake_direction
if pyxel.btnp(pyxel. KEY_Q ):
pyxel.quit()
arrow_keys_pressed = []
for key in ARROW_KEYS :
if pyxel.btnp(key):
arrow_keys_pressed.append(key)
for key in arrow_keys_pressed:
if key == pyxel. KEY_UP :
snake_direction = UP
elif key == pyxel. KEY_DOWN :
snake_direction = DOWN
elif key == pyxel. KEY_LEFT :
snake_direction = LEFT
elif key == pyxel. KEY_RIGHT :
snake_direction = RIGHT
snake_head = snake_geometry[ - 1 ]
new_snake_head = [
snake_head[ 0 ] + snake_direction[ 0 ],
snake_head[ 1 ] + snake_direction[ 1 ],
]
if (
new_snake_head in snake_geometry
or new_snake_head in rocks
or (
new_snake_head[ 0 ] < 0
or new_snake_head[ 0 ] > WIDTH - 1
or new_snake_head[ 1 ] < 0
or new_snake_head[ 1 ] > HEIGHT - 1
)
):
snake_geometry = snake_geometry[ 1 : - 1 ] + [snake_head]
elif new_snake_head == fruit:
snake_geometry = snake_geometry + [new_snake_head]
spawn_new_fruit()
else :
snake_geometry = snake_geometry[ 1 :] + [new_snake_head]
def draw ():
pyxel.cls( WHITE )
pyxel.pset(fruit[ 0 ], fruit[ 1 ], PINK )
for x, y in rocks:
pyxel.pset(x, y, BLACK )
for x, y in snake_geometry[: - 1 ]:
pyxel.pset(x, y, DARK_GREEN )
snake_head = snake_geometry[ - 1 ]
pyxel.pset(snake_head[ 0 ], snake_head[ 1 ], LIGHT_GREEN )
pyxel.run(update, draw)
Fine-Grained Functions
Solution import pyxel
from constants import *
def spawn_new_rocks ():
global rocks
rocks = []
for i in range ( WIDTH ):
for j in range ( HEIGHT ):
if (i + j) % 5 == 0 and (i - j) % 11 == 0 :
rocks.append([i, j])
def spawn_new_snake ():
global snake_geometry, snake_direction
snake_geometry = [
[ 10 , 15 ],
[ 11 , 15 ],
[ 12 , 15 ],
]
snake_direction = RIGHT
def spawn_new_fruit ():
global fruit
while True :
fruit = [pyxel.rndi( 0 , WIDTH - 1 ), pyxel.rndi( 0 , HEIGHT - 1 )]
if fruit not in snake_geometry and fruit not in rocks:
break
def spawn_everything ():
spawn_new_rocks()
spawn_new_snake()
spawn_new_fruit()
def handle_events ():
global snake_direction
if pyxel.btnp(pyxel. KEY_Q ):
pyxel.quit()
arrow_keys_pressed = []
for key in ARROW_KEYS :
if pyxel.btnp(key):
arrow_keys_pressed.append(key)
for key in arrow_keys_pressed:
if key == pyxel. KEY_UP :
snake_direction = UP
elif key == pyxel. KEY_DOWN :
snake_direction = DOWN
elif key == pyxel. KEY_LEFT :
snake_direction = LEFT
elif key == pyxel. KEY_RIGHT :
snake_direction = RIGHT
def snake_move ():
global snake_geometry, fruit
snake_head = snake_geometry[ - 1 ]
new_snake_head = [
snake_head[ 0 ] + snake_direction[ 0 ],
snake_head[ 1 ] + snake_direction[ 1 ],
]
if (
new_snake_head in snake_geometry
or new_snake_head in rocks
or (
new_snake_head[ 0 ] < 0
or new_snake_head[ 0 ] > WIDTH - 1
or new_snake_head[ 1 ] < 0
or new_snake_head[ 1 ] > HEIGHT - 1
)
):
snake_geometry = snake_geometry[ 1 : - 1 ] + [snake_head]
elif new_snake_head == fruit:
snake_geometry = snake_geometry + [new_snake_head]
spawn_new_fruit()
else :
snake_geometry = snake_geometry[ 1 :] + [new_snake_head]
def update ():
handle_events()
snake_move()
def draw ():
pyxel.cls( WHITE )
snake_body = snake_geometry[: - 1 ]
snake_head = snake_geometry[ - 1 ]
for x, y in snake_body:
pyxel.pset(x, y, DARK_GREEN )
pyxel.pset(snake_head[ 0 ], snake_head[ 1 ], LIGHT_GREEN )
for x, y in rocks[: - 1 ]:
pyxel.pset(x, y, BLACK )
pyxel.pset(fruit[ 0 ], fruit[ 1 ], PINK )
pyxel.init( WIDTH , HEIGHT , fps = FPS )
spawn_everything()
pyxel.run(update, draw)
Draw Refactoring
Implement a function display
that draws list of colored pixels.
Make sure that it works in a way that we can replace the original
implementation of draw
with:
def draw ():
pyxel.cls( WHITE )
snake_body = snake_geometry[: - 1 ]
snake_head = snake_geometry[ - 1 ]
display( DARK_GREEN , snake_body)
display( LIGHT_GREEN , [snake_head])
display( BLACK , rocks)
display( PINK , [fruit])
Then extend the display
function so that:
when its second argument is not specified, it should apply the color
to all pixels of the screen;
when the second argument is a single pixel and not a list of pixels,
it should apply the color to this pixel.
Simplify the implementation of draw
to leverage these extensions
Solution The first version of draw
can be written:
def display (color, pixels):
for x, y in pixels:
pyxel.pset(x, y, color)
Its extension can be written:
def display (color, pixels = None ):
if pixels is None :
pyxel.cls(color)
elif len (pixels) >= 1 and type (pixels[ 0 ]) == int :
display(color, [pixels])
else :
for x, y in pixels:
pyxel.pset(x, y, color)
After this second change, we can rewrite draw
as:
def draw ():
display( WHITE )
snake_body = snake_geometry[: - 1 ]
snake_head = snake_geometry[ - 1 ]
display( DARK_GREEN , snake_body)
display( LIGHT_GREEN , snake_head)
display( BLACK , rocks)
display( PINK , fruit)
Move Snake
Introduce a crash
function such that snake_move
may be replaced with the code:
def snake_move ():
global snake_geometry, fruit
snake_head = snake_geometry[ - 1 ]
new_snake_head = [
snake_head[ 0 ] + snake_direction[ 0 ],
snake_head[ 1 ] + snake_direction[ 1 ],
]
if crash(new_snake_head):
snake_geometry = snake_geometry[ 1 : - 1 ] + [snake_head]
elif new_snake_head == fruit:
snake_geometry = snake_geometry + [new_snake_head]
spawn_new_fruit()
else :
snake_geometry = snake_geometry[ 1 :] + [new_snake_head]
Document the role of this function using an appropriate docstring.
Solution def crash (head):
"""
Returns True if the snake crashes:
- into itself,
- into a rock,
- or into the arena wall.
"""
return (
head in snake_geometry
or head in rocks
or (
head[ 0 ] < 0
or head[ 0 ] > WIDTH - 1
or head[ 1 ] < 0
or head[ 1 ] > HEIGHT - 1
)
)
Introduce a few comments into the snake_move
function to explain
the parts that may still be a bit cryptic (I am thinking about the
evolution of the snake geometry). I’d like the game logic to be as
explicit as possible here!
Solution def snake_move ():
global snake_geometry, fruit
snake_head = snake_geometry[ - 1 ]
new_snake_head = [
snake_head[ 0 ] + snake_direction[ 0 ],
snake_head[ 1 ] + snake_direction[ 1 ],
]
if crash(new_snake_head): # 💀
# 🐍 keep the old head and shrink the tail
snake_geometry = snake_geometry[ 1 : - 1 ] + [snake_head]
elif new_snake_head == fruit: # 🍓
# 🐍 move the head and grow the tail
snake_geometry = snake_geometry + [new_snake_head]
spawn_new_fruit()
else :
# 🐍 move the head; the tail follows
snake_geometry = snake_geometry[ 1 :] + [new_snake_head]
Generic Event Handling
Our current event handle_events
code is not completely satisfying: if we
need to manage more events, for example if we want to add a pause feature,
we need to modify and grow the function.
To prevent this, we replace it with a generic event handling system,
in which we never change the handle_events
function when we introduce
new events. The core of this system is described in the following
events.py
file:
import pyxel
_handlers = []
def register (event, handler):
_handlers.append([event, handler])
def handle ():
for event, handler in _handlers:
if pyxel.btnp(event):
handler()
This new module should be imported into our main Python file:
import events
and the update function modified as follows:
def update ():
events.handle()
snake_move()
You can now delete the code of the handle_events
function!
Initially, this events.handle
function does nothing, but now we can
easily register any action (implemented as as function) that we want to associate with an event.
For example, to quit the game when the Q
key is pressed, we can write:
events.register(pyxel. KEY_Q , pyxel.quit)
Use this new system to handle the arrows key press: define appropriate
move_up
, move_right
, move_down
, and move_left
actions, and
register them with the appropriate events.
Solution def move_up ():
global snake_direction
snake_direction = UP
def move_right ():
global snake_direction
snake_direction = RIGHT
def move_down ():
global snake_direction
snake_direction = DOWN
def move_left ():
global snake_direction
snake_direction = LEFT
events.register(pyxel. KEY_UP , move_up)
events.register(pyxel. KEY_RIGHT , move_right)
events.register(pyxel. KEY_DOWN , move_down)
events.register(pyxel. KEY_LEFT , move_left)
Use this new system to pause/unpause the game when the P
key is pressed
(you will need to slightly modify the snake_move
function).
Solution paused = False
def pause ():
global paused
paused = not paused
events.register(pyxel. KEY_P , pause)
def snake_move ():
global snake_geometry, fruit
if paused:
return
...
Synthesis
Here is the final result of our refactoring:
Solution constants.py
:
# Screen Size
WIDTH = HEIGHT = 30
# Frame Rate
FPS = 10
# Colors
BLACK = 0
WHITE = 7
RED = 8
DARK_GREEN = 3
LIGHT_GREEN = 11
# Geometry
UP = [ 0 , - 1 ]
RIGHT = [ 1 , 0 ]
DOWN = [ 0 , 1 ]
LEFT = [ - 1 , 0 ]
events.py
:
import pyxel
_handlers = []
def register (event, handler):
_handlers.append([event, handler])
def handle ():
for event, handler in _handlers:
if pyxel.btnp(event):
handler()
snake.py
:
import pyxel
from constants import *
import events
def spawn_new_rocks ():
global rocks
rocks = []
for i in range ( WIDTH ):
for j in range ( HEIGHT ):
if (i + j) % 5 == 0 and (i - j) % 11 == 0 :
rocks.append([i, j])
def spawn_new_snake ():
global snake_geometry, snake_direction
snake_geometry = [
[ 10 , 15 ],
[ 11 , 15 ],
[ 12 , 15 ],
]
snake_direction = RIGHT
def spawn_new_fruit ():
global fruit
while True :
fruit = [
pyxel.rndi( 0 , WIDTH - 1 ),
pyxel.rndi( 0 , HEIGHT - 1 )
]
if fruit not in snake_geometry and fruit not in rocks:
break
def spawn_everything ():
spawn_new_rocks()
spawn_new_snake()
spawn_new_fruit()
events.register(pyxel. KEY_Q , pyxel.quit)
def move_up ():
global snake_direction
snake_direction = UP
def move_right ():
global snake_direction
snake_direction = RIGHT
def move_down ():
global snake_direction
snake_direction = DOWN
def move_left ():
global snake_direction
snake_direction = LEFT
events.register(pyxel. KEY_UP , move_up)
events.register(pyxel. KEY_RIGHT , move_right)
events.register(pyxel. KEY_DOWN , move_down)
events.register(pyxel. KEY_LEFT , move_left)
paused = False
def pause ():
global paused
paused = not paused
events.register(pyxel. KEY_P , pause)
def crash (head):
"""
Returns True if the snake crashes:
- into itself,
- into a rock,
- or into the arena wall.
"""
return (
head in snake_geometry
or head in rocks
or (
head[ 0 ] < 0
or head[ 0 ] > WIDTH - 1
or head[ 1 ] < 0
or head[ 1 ] > HEIGHT - 1
)
)
def snake_move ():
global snake_geometry, fruit
if paused:
return
snake_head = snake_geometry[ - 1 ]
new_snake_head = [
snake_head[ 0 ] + snake_direction[ 0 ],
snake_head[ 1 ] + snake_direction[ 1 ],
]
if crash(new_snake_head): # 💀
# 🐍 keep the old head and shrink the tail
snake_geometry = snake_geometry[ 1 : - 1 ] + [snake_head]
elif new_snake_head == fruit: # 🍓
# 🐍 move the head and grow the tail
snake_geometry = snake_geometry + [new_snake_head]
spawn_new_fruit()
else :
# 🐍 move the head; the tail follows
snake_geometry = snake_geometry[ 1 :] + [new_snake_head]
def update ():
events.handle()
snake_move()
def display (color, pixels = None ):
if pixels is None :
pyxel.cls(color)
elif len (pixels) >= 1 and isinstance (pixels[ 0 ], int ):
display(color, [pixels])
else :
for x, y in pixels:
pyxel.pset(x, y, color)
def draw ():
display( WHITE )
snake_body = snake_geometry[: - 1 ]
snake_head = snake_geometry[ - 1 ]
display( DARK_GREEN , snake_body)
display( LIGHT_GREEN , snake_head)
display( BLACK , rocks)
display( PINK , fruit)
pyxel.init( WIDTH , HEIGHT , fps = FPS )
spawn_everything()
pyxel.run(update, draw)