The Great Refactoring

Time to clean up our code before it gets out of hand!

avatar

Sébastien Boisgérault
Associate Professor, ITN Mines Paris – PSL

Blacksmith

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

  • Introduce spawn_new_rocks and spawn_new_snake functions (similar to spawn_new_fruit) and then a spawn_everything function.

  • Split the large update function into handle_events and snake_move 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(list(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)