Spaceship: An Asteroids Clone

"Spaceship" aka Asteroids was one of the first projects that I ever made that ran on a "real" computer. Up until then, pretty much all of my experience with programming was on my TI-83+. I had forgotten it even existed until I stumbled onto an old zip file in the depths of my Google Drive trash folder.

It started because I wanted to learn a new language that would run on my home computer, and because I was a high school student of course I wanted to start making some games. After looking into good languages for a beginner to learn I finally settled on Python. I ended up making some simple games like "Guess the Number" and other beginner projects, but eventually I wanted to make something more serious. The only problem was that I had no idea what to do. After searching the internet for "python" and "game" of course I discovered PyGame. And I have to say that I was blown away, some of the examples that I saw on their website were pretty impressive. I had found my library. Next I grabbed some friends and we were off and running.

I started by just playing around with the library learning how to draw rectangles and move things around the screen. I think I built a simple platformer first, but then I started on Spaceship. It wasn't called Spaceship at the time though. It was just a generic object avoidance game until someone pointed out that it looked an awful lot like Asteroids. After we realized that we had inadvertently created an Asteroids clone we re-skinned the game with a space theme and Spaceship was born.

Now, lets take a look at the game itself:

When I first tried to run it I was actually surprised that it worked at all to be honest. Granted I'm lucky that PyPI hasn't shut down its Python 2.7 packages at the time of this writing but still...it works!

It doesn't look the most polished, but that matches the retro vibe that we were going for. The Play button functions as expected and so does Quit, but Options and High Scores just exit the game rather than doing anything useful.

Once in the game it pretty much works as expected. You can use the arrow keys or WASD to move your ship around and your mouse to turn your ship. Clicking will shoot a bomb that blows up the asteroids if you can't avoid them. The escape key will bring up a pause menu with the options to resume, go to the main menu, or quit the game.

I remember getting farther than this when we originally worked on the game, but that work must have been lost to time. I remember implementing the High Scores and a re-spawn feature. I also made the window re-sizable and even enabled full screen. There was also a credits screen Easter egg that triggered when you clicked the dot in the "i" of Spaceship. We must have been close to implementing that one because I found the credits image in the zip file but I couldn't find any code that would trigger it.

Speaking of the zip file, I was actually excited when I opened it up and saw two different versions of the source code in it. I thought maybe I would get to see multiple versions of the application because of my basic attempt at source control. Unfortunately, the only difference between the files was the addition of a few newlines.

Now lets get into the fun part...the code.

Oh boy the code, where do I even start? It makes me cringe even looking at it. The first thing that I should mention is that none of it is PEP-8 or any other consistent coding standard. The second thing to note is that there are no comments anywhere. Despite these two factors, the code is still simple enough to be able to follow along somewhat coherently.

Lets start with the top:

import pygame, sys, random, math
from pygame.locals import *

pygame.init()
mainClock=pygame.time.Clock()

width=600
height=600
window=pygame.display.set_mode((width, height))
pygame.display.set_caption('Game_Test')

black=(0,0,0)
white=(255,255,255)
green=(0,255,0)
red=(255,0,0)

menu=pygame.image.load('mainmenu.png').convert()
pausebg=pygame.image.load('pausebg.png').convert()
font=pygame.font.SysFont(None,36)
font2=pygame.font.SysFont(None,20)

Here we have some imports and global variable definitions. I might refactor some of this stuff later but for now lets just run the linter and do some minor cleanup.

# Python imports
import math
import random
import sys

# Library imports
import pygame
from pygame.locals import *
from pygame.color import THECOLORS

# Set constants
WIDTH = 600
HEIGHT = 600

# Initialize pygame
pygame.init()

# Setup the pygame window
mainClock = pygame.time.Clock()
window = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption('Spaceship')

# Setup pygame constants
FONT_LARGE = pygame.font.SysFont(None, 36)
FONT_SMALL = pygame.font.SysFont(None, 20)

# Load game assets
menu = pygame.image.load('mainmenu.png').convert()
pausebg = pygame.image.load('pausebg.png').convert()

background = pygame.image.load('stars.gif').convert()
background = pygame.transform.scale(background, (WIDTH, HEIGHT))

bombpic = pygame.image.load('bomb.jpg').convert()
bombpic = pygame.transform.scale(bombpic, (20, 20))
bombpic.set_colorkey(THECOLORS['white'])

playerpic = pygame.image.load('spaceship.png').convert()
playerpic = pygame.transform.scale(playerpic, (30, 30))
playerpic.set_colorkey(THECOLORS['white'])

rockpic = pygame.image.load('rock.png').convert()
rockpic.set_colorkey(THECOLORS['white'])

explosion = pygame.image.load('explosion.jpg')
explosion = pygame.transform.scale(explosion, (60, 60))
explosion.set_colorkey(THECOLORS['white'])

Much better, I pulled all of the instances where an image is loaded to the top. We don't need to load the same image every time we start a new game. This has the added benefit that we will know immediately if an asset is missing. There's still room for improvement here but lets move on to the next section for now.

The main menu is where things start to get a little interesting.

First off, the entire function is an infinite loop. Every frame it draws the background image, sets up the clickable areas, processes the events for those areas, and updates the screen. That is already a lot of responsibility for one function but I think the most interesting thing is what happens when you click play. Clicking play calls the game() function which has an infinite loop to handle the game events. Going a step further, pressing pause from game calls the pause() function which also contains another infinite loop! At this point we are three infinite loops deep and hopefully are wondering if we can do better.

Ideally we would only have one infinite loop that handles all of the drawing and event handling no matter which part of the program that we are in. So lets start with that:

def main():
    while True:
    	pass  # Draw something here
    	for event in pygame.event.get():
        	pass  # Handle events here

That was easy! It sure would be nice if this function did anything other than just loop though.

class State:
    __metaclass__ = ABCMeta

    @abstractmethod
    def draw(self):
        pass

    @abstractmethod
    def handle_event(self, event):
        pass  # Return next state or self to stay on current state
        

def main():
    state = MainMenuState()
    while True:
        state.draw()
        for event in pygame.event.get():
            state = state.handle_event(event)

That's better, we now have a small state machine that will draw to the screen with the draw() method and return the next state based on input events with the handle_event() method. Now it's just a matter of defining the states and their transitions. MainMenuState seems like it would be a good place to start, since that will be the initial state on startup.

def exit_game():
    # Cleanup resources and quit the game
    sys.exit()


class MainMenuState(State):
    def __init__(self):
        self.play_rect = pygame.Rect(window.get_rect().centerx - 55,
                                     window.get_rect().centery - 90,
                                     125, 55)
        self.options_rect = pygame.Rect(window.get_rect().centerx - 75,
                                        window.get_rect().centery - 20,
                                        167, 55)
        self.highscores_rect = pygame.Rect(window.get_rect().centerx - 110,
                                           window.get_rect().centery + 45,
                                           250, 55)
        self.quit_rect = pygame.Rect(window.get_rect().centerx - 45,
                                     window.get_rect().centery + 105,
                                     105, 45)

Before we start on the meat of the interface we need to define a few things that will make our lives easier later on. The exit_game() global function is a place where we can clean up any resources that need to be cleaned up before calling sys.exit() to end the program. In the class itself we will also want to define some rectangles, these will be our clickable areas later on.

def draw(self):
    # Draw the background image to erase the previous screen
    window.blit(menu, (0, 0))

    # If the mouse is in a menu option rectangle then draw a line around it
    if self.play_rect.collidepoint(pygame.mouse.get_pos()):
        pygame.draw.rect(window, THECOLORS['red'], self.play_rect, 2)

    if self.options_rect.collidepoint(pygame.mouse.get_pos()):
        pygame.draw.rect(window, THECOLORS['red'], self.options_rect, 2)

    if self.highscores_rect.collidepoint(pygame.mouse.get_pos()):
        pygame.draw.rect(window, THECOLORS['red'], self.highscores_rect, 2)

    if self.quit_rect.collidepoint(pygame.mouse.get_pos()):
        pygame.draw.rect(window, THECOLORS['red'], self.quit_rect, 2)

    pygame.display.update()

MainMenuState.draw() is the first place that the game really takes shape. The first thing that we do in this function is to draw the mainmenu.png image over the entire screen, this will make more sense later on. Next we detect if the mouse pointer is within any of the rectangles that we defined before. If it is, then we will draw a border around that rectangle. This is also why we draw the main menu image every frame. In PyGame there isn't a way to undraw something from the screen, so we want to reset the screen in between frames so that the rectangles reset when the mouse is not over them. The last step is to tell PyGame that we are done drawing to the screen so that it can flush everything to our display.  PyGame also has an option to only update parts of the screen by passing pygame.display.update() a list of  "dirty" rectangles (the ones that have changed since the last update). If we wanted to do that, this would be the place to do so.

Finally we made it to the event handler! This is the most interesting part of the main menu.

def handle_event(self, event):
    # Handle window close events
    if event.type == QUIT:
        exit_game()

    # Pressing escape also closes the game
    if event.type == KEYDOWN:
        if event.key == K_ESCAPE:
            exit_game()

    if event.type == MOUSEBUTTONUP:
        if self.play_rect.collidepoint(event.pos):
            return GameState()

        if self.options_rect.collidepoint(event.pos):
            return self

        if self.highscores_rect.collidepoint(event.pos):
            return self

        if self.quit_rect.collidepoint(event.pos):
            exit_game()
    return self

The first two parts check if the user has exited out of the game with the 'X' or by pressing the 'Escape' key. The next part checks to see if the user has clicked inside any of the rectangles that we defined before. If they have, then it will return the next state that the game should transition to. Returning self keeps the current state and therefore doesn't transition the interface. It's important to remember that the outer loop is expecting a state every frame and so the fall through case of this function should be to return self as well. If it returns None there will be an error because None doesn't have a draw() function. So far the only new state that is returned is the GameState so lets look at that next.

The game() function is an awful mess that isn't really worth looking at as a whole. We can do better with GameState by starting with consolidating all of the drawing and event handling. But like with the MainMenuState we first need to do some setup.

def __init__(self):
    self.score = 0
    self.rockcounter = 0
    self.newrock = 40
    self.rocknumber = 5
    self.player = pygame.Rect(window.get_rect().centerx,
                              window.get_rect().centery,
                              30, 30)
    self.speed = 6

    self.rocks = []
    for i in range(self.rocknumber):
        rocksize = random.randint(20, 40)
        rockx = random.randint(-rocksize, WIDTH)
        rocky = random.randint(-rocksize, 0)
        self.rocks.append({'rect': pygame.Rect(rockx, rocky, rocksize, rocksize),
                           'xspeed': math.cos(math.atan2(self.player.centery - rocky,
                                                         math.fabs(rockx - self.player.centerx))),
                           'yspeed': math.sin(math.atan2(self.player.centery - rocky,
                                                         math.fabs(rockx - self.player.centerx))),
                           'pic': pygame.transform.scale(rockpic, (rocksize, rocksize))})

    self.moveleft = False
    self.moveright = False
    self.moveup = False
    self.movedown = False
    self.bombs = []

This is pretty ugly but we can come back to it once we have a better idea of what is needed by the other functions. Next we are going to consolidate all of the display functionality into draw().

def draw(self):
    # Draw the background
    window.fill(THECOLORS['black'])
    window.blit(background, (0, 0))

Again the first thing that we want to do is set the background to all black and then draw the background image. We need to draw the background as black first because the image isn't the right size to fill up the screen. Next we will move and rotate the player image.

if moveleft and player.left > speed:
    player.left -= speed
if moveup and player.top > speed:
    player.top -= speed
if movedown and player.bottom < HEIGHT - speed:
    player.bottom += speed
if moveright and player.right < WIDTH - speed:
    player.right += speed
            
# Rotate the player
mousex, mousey = pygame.mouse.get_pos()
playerpicrotated = pygame.transform.rotate(playerpic, -math.degrees(
    math.atan2(self.player.centery - mousey, self.player.centerx - mousex)) + 45)
window.blit(playerpicrotated, self.player)

In the old code above, we checked to see if adding or subtracting the speed would result in the player leaving the screen. If it would, we wouldn't move the player in that direction so that they would always be visible. We can make this better by forcing the player rectangle within the edges of the screen with the PyGame function clamp. This makes it so that the player can actually make it the last few pixels and touch the edge. It also removes some of the complicated edge cases that we were checking. In code that looks like this:

# Move the player
if self.moveleft:
    self.player.left -= self.speed
if self.moveup:
    self.player.top -= self.speed
if self.movedown:
    self.player.bottom += self.speed
if self.moveright:
    self.player.right += self.speed
self.player.clamp_ip(window.get_rect())

There is also some semi-complicated math that is used to rotate the player image to face the mouse pointer. To understand the math we need to have a quick refresher in trigonometry. one of the basic trig functions is the tangent function. It is defined as \(\tan(\theta) = \frac{y}{x}\) where \(\theta\) is the angle that we are looking for. Taking the arctangent of both sides leaves us with an equation for \(\theta\).

\[\arctan(\tan(\theta)) = \arctan(\frac{y}{x})\]
\[\theta = \arctan(\frac{y}{x})\]

However there is still a problem with this formula. The range (output) of the arctangent function is limited between \(\frac{\pi}{2}\) to \(-\frac{\pi}{2}\). this is because \(\frac{-y}{x}\) is equal to \(\frac{y}{-x})\). Python solves this problem for us by providing the atan2 function in the math library, this function takes two arguments, y and x, and by looking at the sign of each argument it can disambiguate the above problem and tell us the correct angle in the range \(-\pi\) to \(\pi\).

The only thing left to do is to pick x and y so that they form an angle from the player to the mouse. We can do that by subtracting the x and y coordinates of the mouse from the x and y coordinates of the player but we need to be careful to make sure that the signs of each subtraction match what atan2 is expecting so that we get the correct rotation in the end. If the mouse is above the player we want the result to be positive, and if the mouse is to the left of the player we want the result to be negative. Because in the PyGame coordinate system the origin is in the top left and increases down and to the right we have to be a little creative the get the answers we expect.

Subtracting the mouse y value from the player y value gives the correct answer because the mouse y value will be smaller when it is above the player and larger when it is below the player. The opposite is true for the x coordinates so we have to flip the order we subtract them.

After translating all of the rotation logic into code we get this:

# Rotate the player
mousex, mousey = pygame.mouse.get_pos()

# Subtract player x from mouse x to match atan2 expected inputs
player_angle_x = mousex - self.player.centerx
player_angle_y = self.player.centery - mousey

# Subtract 135 to offset the angle of the spaceship in the image
rotation = math.degrees(math.atan2(player_angle_y, player_angle_x)) - 135

player_rotated = pygame.transform.rotate(playerpic, rotation)
window.blit(player_rotated, self.player)

Next let's look at the bombs that are currently on the screen.

# move the bombs
for i in self.bombs:
    # If still on the screen then draw the next frame
    if i['rect'].bottom > -25 and i['rect'].top < HEIGHT + 25 and i['rect'].left < WIDTH + 25 and i['rect'].right > -25:
        i['rect'].centerx += i['xspeed'] * self.speed
        i['rect'].centery += i['yspeed'] * self.speed

    # If bomb left the screen then remove it from the list
    if i['rect'].bottom <= -25 or i['rect'].top >= HEIGHT + 25 or i['rect'].left >= WIDTH + 25 or i['rect'].right <= -25:
        self.bombs.remove(i)

    window.blit(i['pic'], i['rect'])

Rather than remove items from the list while we are iterating over it, a better idea is to filter the list ahead of time and then iterate over the smaller list.

# Move the bombs
self.bombs = [bomb for bomb in self.bombs if bomb['rect'].colliderect(window.get_rect())]
for bomb in self.bombs:
    bomb['rect'].centerx += bomb['xspeed'] * self.speed
    bomb['rect'].centery += bomb['yspeed'] * self.speed
    window.blit(bomb['pic'], bomb['rect'])

We also want to check if the bomb has hit any of the rocks on screen but before we do that let's define a new function to spawn a new rock into the game and clean up the __init__ function to use it.

def __init__(self):
    self.score = 0

    self.player = pygame.Rect(window.get_rect().centerx, window.get_rect().centery, 30, 30)
    self.speed = 6
    self.moveleft = False
    self.moveright = False
    self.moveup = False
    self.movedown = False

    self.bombs = []

    self.rocks = []
    self.num_rocks = 5
    for i in range(self.num_rocks):
        self.rocks.append(self.spawn_rock())

def spawn_rock(self):
    rocksize = random.randint(20, 40)
    rockx = random.randint(-rocksize, WIDTH+rocksize)
    rocky = random.randint(-rocksize, 0)
    return {'rect': pygame.Rect(rockx, rocky, rocksize, rocksize),
            'xspeed': math.cos(math.atan2(self.player.centery - rocky, math.fabs(rockx - self.player.centerx))),
            'yspeed': math.sin(math.atan2(self.player.centery - rocky, math.fabs(rockx - self.player.centerx))),
            'pic': pygame.transform.scale(rockpic, (rocksize, rocksize))}

The spawn_rock method will be useful when we start talking about rocks and __init__ is starting to look much better now that all of that logic has been delegated elsewhere. Now we can go back to the bombs and check if they have hit any of the rocks.

for bomb in self.bombs:
    if bomb['exploded']:
        bomb['explosion_counter'] -= 1
        window.blit(explosion, bomb['rect'])
    else:
        bomb['rect'].centerx += bomb['xspeed'] * self.speed
        bomb['rect'].centery += bomb['yspeed'] * self.speed
        window.blit(bomb['pic'], bomb['rect'])

        # Remove a rock from the list if it was hit and start the explosion counter
        rock_index = bomb['rect'].collidelist([r['rect'] for r in self.rocks])
        if rock_index != -1:
            self.rocks.pop(rock_index)
            self.rocks.append(self.spawn_rock())
            bomb['exploded'] = True

If the current bomb has exploded we draw the explosion graphic instead of the bomb graphic and decrement the frame counter. This allows the explosion to stay on screen longer so that we can actually see it. Otherwise, we check if the current bomb hit any of the rocks and remove the rock that was hit and replace it in the list with spawn_rock. One last important piece of code to remember is to clean up the explosions after their frame counter reaches 0.

self.bombs = [bomb for bomb in self.bombs
              if bomb['rect'].colliderect(window.get_rect()) and bomb['explosion_counter'] > 0]

Finally we are on to the rocks themselves, we need to start with removing the rocks that are off the screen similar to how we did with the bombs.

self.rocks = [rock for rock in self.rocks if rock['rect'].colliderect(window.get_rect())]

But if we ran the game now we might notice that there is a bug in the above statement. Because rocks get spawned off screen they will get deleted as soon as they are created. We can fix this by adding a new spawn rectangle that we will use to create rocks in, and if they collide with this rectangle then we will also consider them "on the screen". We can also take this opportunity to improve the spawn_rock function so that it actually aims rocks at the player.

We will use spawn_rect to determine the initial location of the rock and then find the x and y distances to the player. Dividing by the hypotenuse of that triangle gives us numbers between 0 and 1 to use as the x and y speed of the rock. A number between 0 and 1 is good because we will be multiplying these numbers by the speed scaling factor and we don't want them to go too fast that we can't react. It also has the nice benefit that when truncated to an integer it doesn't always look like the rock was aimed directly at the player because of rounding errors.

def __init__(self):
	self.spawn_rect = pygame.Rect(0, -40, WIDTH, 40)
    ...
    
def spawn_rock(self):
    rocksize = random.randint(20, 40)
    rockx = random.randint(self.spawn_rect.left, self.spawn_rect.right)
    rocky = random.randint(self.spawn_rect.top, self.spawn_rect.bottom)
    x_dist = self.player.centerx - rockx
    y_dist = self.player.centery - rocky
    total_dist = math.sqrt((x_dist**2) + (y_dist**2))
    return {'rect': pygame.Rect(rockx, rocky, rocksize, rocksize),
            'xspeed': x_dist/total_dist,
            'yspeed': y_dist/total_dist,
            'pic': pygame.transform.scale(rockpic, (rocksize, rocksize))}
            
def draw(self):
	...
    self.rocks = [rock for rock in self.rocks
                      if rock['rect'].collidelist([window.get_rect(), self.spawn_rect]) != -1]

After fixing this, we need to replace all of the rocks that we had previously removed and increment the score for each one.

for i in range(self.num_rocks - len(self.rocks)):
    self.rocks.append(self.spawn_rock())
    self.score += 1

And the last step is easy, just iterate through the list of rocks and update their positions on the screen.

for rock in self.rocks:
    rock['rect'].centerx += rock['xspeed'] * self.speed
    rock['rect'].centery += rock['yspeed'] * self.speed

    window.blit(rock['pic'], rock['rect'])

One of the last things that draw needs to do is to check if the player has collided with any rocks. If they have, then we will set the game_over variable and post an event for the handle_event method to read and advance to the GameOverState. The very last thing for draw to do is to draw the score to the screen and update the difficulty every 50 points.

# Check for player collision
if self.player.collidelist([rock['rect'] for rock in self.rocks]) != -1:
    self.game_over = True
    pygame.event.post(pygame.event.Event(USEREVENT, {}))

# Every 50 points increase the number of rocks by 5
if self.score % 50 == 0:
    self.num_rocks += 5
    self.score += 1

# Draw the score
window.blit(FONT_SMALL.render(str(self.score), True, THECOLORS['red'], THECOLORS['black']), (350, 25))

pygame.display.update()
mainClock.tick(40)

The GameState event handler is a lot of what you have seen before, if a known key is pressed then it sets some instance variables so that the draw method knows what to do. If game_over is set then it just returns a new GameOverState, and if the mouse is clicked it adds a new bomb to the bomb array. The only interesting part of it is that when then pause button is pressed it will return a new PauseState object instantiated with the current GameState as the argument. This is so that when the PauseState is exited, it can return the state passed to it instead of creating an entirely new game.

I was slightly concerned that this might cause a memory leak. But because GameState doesn't maintain a reference to the PauseState, the only reference to the PauseState is in the main loop. Once the PauseState is unreferenced by the main loop it will get garbage collected by Python and we will be back to only having one state in memory.

I will spare you the details of how GameOverState and PauseState work since they are very similar to the rest. If you are interested you can check out the project on GitHub and look at the code there, the specific branch with all of these changes can be found here. Overall I'm happy with the new state machine design. I think it will be easy to extend in the future if more states are desired, like for the high scores and credits. If you are curious as to what the state diagram currently looks like here it is:

There are definitely still things that I would change such as finishing all of the features, upgrading to Python 3.x, and making it so that everything follows a consistent coding standard, but I will save that for another time. For now I'm just having fun playing Spaceship.