Creating Games with Pygame
Python is an excellent language for rapid application development and prototyping. With Pygame, a wrapper built around SDL, the same can be true for games. In addition, because its built on top of Python and SDL, Pygame is highly portable. The only downside is it can be too slow for some computationally intensive types of games. If your game is too slow, the particular sub-routine(s) bringing down your execution speed can be rewritten in C/C++.
This article is intended to be a short introduction and by no means covers all there is to know about Pygame. Using a simple Space Invaders-type game, I present what I feel are the fundamentals of Pygame.
The first step in creating our game is to import Pygame and the other modules we need:
import random, os import pygame from pygame.locals import *
We need the os module for tasks related to opening files; the random module is needed for the AI of our enemy sprites, as we will see later. We then run import pygame, which imports all available Pygame modules from the Pygame package. The final line is optional and puts a subset of the most frequently used Pygame functions and constants into the global name space.
It may seem like we are skipping ahead here, but the last line of our program calls our main function, so we might as well get it out of the way now.
if __name__ == '__main__': main()
Next, we create our main function. The basic steps we are going to follow for our main function are initializing modules, loading game resources, preparing game objects and entering the game loop.
def main(): random.seed() pygame.init()
random.seed() is called to initialize the random number generator. Optionally, one parameter can be given to random.seed(), and this parameter can be any hashable object. If the parameter is omitted or is listed as None, the system time is used.
pygame.init() attempts to initialize all the Pygame modules for you. All the Pygame modules do not need to be initialized, but this command automatically initializes the ones that do. Alternatively, you can initialize each Pygame module by hand.
Most modules have a quit() function that cleans up the module, but there is no need to call it manually, as there is with SDL. Pygame calls these functions automatically and exits cleanly when Python finishes.
The next part of our main function is creating the display surface. The surface is one of, if not the most, important elements of Pygame. A surface is like a canvas on which you can draw lines, images and so on. Surfaces can be any size, and you can have as many of them as you want. The first of the following three code lines creates the display surface. I have found that running the game in a window rather than in full screen mode is a good idea during development. This way, if your game crashes for some reason, you don't have to worry about the screen resolution being incorrect, among other possible problems. In the case that your game is played in a window rather than full-screen mode, the second line sets the title bar caption. The third line makes the mouse cursor invisible over the game window.
screen = pygame.display.set_mode((640, 480), FULLSCREEN) pygame.display.set_caption('Space Game') pygame.mouse.set_visible(False)
To play the game in a window, remove the FULLSCREEN option as follows:
screen = pygame.display.set_mode((640, 480))
Before we can create our background, we need to create a function to load images for us. For those new to programming, the load_image and the load_sound functions should be placed above the main function. Our function load_image takes the name of the image file as a parameter and, optionally, a color key. The color key tells Pygame that all pixels of a particular color should be transparent. In our game, we keep all of our images and sounds in a directory called data. The command os.path.join creates the complete path to our file, in a platform-independent manner. Then, we try to open the file.
From here, we convert our images to SDL's internal format, which greatly increases the speed at which images are blitted. This is so because SDL does not have to do the conversion on the fly every time the image is blitted. We then set the color key of the image, if we have one. If the color key is -1, the color of the pixel in the top left corner is used. Finally, we return the image and the rectangular object containing it.
def load_image(file_name, colorkey=None): full_name = os.path.join('data', file_name) try: image = pygame.image.load(full_name) except pygame.error, message: print 'Cannot load image:', full_name raise SystemExit, message image = image.convert() if colorkey is not None: if colorkey is -1: colorkey = image.get_at((0,0)) image.set_colorkey(colorkey, RLEACCEL) return image, image.get_rect()
Next, we load the background image using the function we just defined. We then blit the image to our screen surface.
background_image, background_rect = load_image('stars.bmp') screen.blit(background, (0,0))
We now create our function for loading the required sounds. Our function load sound takes the name of the file we want to load as the only parameter. The first thing we do is create a dummy class called 'No_Sound' that has a play method that does nothing. We then check if the mixer was imported and initialized correctly. If not, we return our dummy class. Then, we form our path to the file in the same way as was done for the load_image function. If the file exists, we load it; otherwise, we print a message to the terminal and return our dummy class. If we have made it to the end of the function without any problems, we return our sound object.
def load_sound(name): class No_Sound: def play(self): pass if not pygame.mixer or not pygame.mixer.get_init(): return No_Sound() fullname = os.path.join('data', name) if os.path.exists(full_name) == False: sound = pygame.mixer.Sound(fullname) else: print 'File does not exist:', fullname return No_Sound return sound
We now can load our four sound effects using the function we just defined. We make shot 1 global, because it can be called from within our enemy sprite class.
explode1 = load_sound("explode1.wav") explode2 = load_sound("explode2.wav") global shot1 shot1 = load_sound("silent.wav") shot2 = load_sound("fx.wav")
Next, we create and initialize three different counters. The first one is required so as to know when the player has been killed, but the last two are needed only for our end-of-game stats.
numberof_hits = 0 numberof_shots = 0 enemy_killed = 0
Now we create the player's ship sprite, all of the sprite groups and the enemy sprites present at the beginning of the game.
Pygame groups have containers used to hold sprites. There are several different classes of groups, each having different methods and properties. The group type that I use most is RenderClear, because it allows for the rendering of all sprites in the group as well as the clearing of them.
First, we create an instance for our Ship class, which we will define momentarily. Then, we create a group called playership_sprite that contains our ship object. Next, we create a group that holds all of our bomb sprites. The bomb_sprite group is empty, as there is no bomb sprite instances created until we actually fire. We then create our enemyship_sprites group and add three instances of class Enemy to it. The Enemy class takes the x-coordinate of the sprite as an argument to the init function; we will define this class momentarily as well. We then create our ebomb_sprite group, which stands for "enemy bomb". We also want the group to be accessible globally so we can access it from our enemy ship class.
ship = Ship() playership_sprite = pygame.sprite.RenderClear(ship) bomb_sprites = pygame.sprite.RenderClear() enemyship_sprites = pygame.sprite.RenderClear() enemyship_sprites.add(Enemy(212)) enemyship_sprites.add(Enemy(320)) enemyship_sprites.add(Enemy(428)) global ebomb_sprites ebomb_sprites = pygame.sprite.RenderClear()
We now define our sprite classes. As a reminder for those new to programming, the class definitions should be placed above the main function. All of our sprite classes consist of only to methods. The __init__ method is called when an instance of the class is created, and the update method is called every time we iterate over the game loop.
The first class we our going to define is the Ship class. In all of our sprite classes we want to inherit the Pygame sprite class, and in our init function we call the Pygame sprite __init__ function. We then load our image and set the center of our sprite to (320,450), which is the start location of the player's ship. Next, we set the number of pixels the image should move with each turn to zero.
class Ship(pygame.sprite.Sprite): """This class is for the players ship""" def __init__(self): pygame.sprite.Sprite.__init__(self) #call Sprite initalizer self.image, self.rect = load_image('ship.bmp', -1) self.rect.center = (320,450) self.x_velocity = 0 self.y_velocity = 0
Now we move on to our update function. All we have to do in our update function is move the position of the sprite and then check to see if it has gone out of bounds. The move_ip method moves the rect object by a given offset. If the sprite has moved our of bounds, it is placed back inside the boundaries. The boundaries are approximately the bottom half of the screen.
def update(self): self.rect.move_ip((self.x_velocity, self.y_velocity)) if self.rect.left < 0: self.rect.left = 0 elif self.rect.right > 640: self.rect.right = 640 if self.rect.top <= 260: self.rect.top = 260 elif self.rect.bottom >= 480: self.rect.bottom = 480
The enemy ship class is a little more complicated, as the update method contains the AI component of the enemy sprite. The class inherits the Pygame sprite class and calls the sprite init function as per usual. The init function takes the starting x position of the sprite. We then load the image of our enemy ship. The center of the rect object is set to the x value given and the y value to 120. The distance variable is used in the AI to know how many turns the ship should move in the same direction. The x and y velocities are initialized to zero, as they are in the player's ship class.
class Enemy(pygame.sprite.Sprite): """This class is for the enemy ships""" def __init__(self, startx): pygame.sprite.Sprite.__init__(self) #call Sprite intializer self.image, self.rect = load_image('eship.bmp', -1) self.rect.centerx = startx self.rect.centery = 120 self.distance = 0 self.x_velocity = 0 self.y_velocity = 0
As I mentioned earlier, the update function contains the AI for our enemy sprites. This particular AI is based heavily on random numbers. The AI is quite simple but works well enough for our purposes in such a simple game. The AI works as follows. If the remaining distance our sprite has to move is zero, it selects a new random distance. The distance is how many iterations through the main game loop the sprite moves in the same direction. Then, a random x and y velocity is selected. It is possible that both the x and y velocities would both be zero, in which case the sprite would stand still for the number of turns indicated by the distance variable. We then move the rect object containing the sprite and decrease the distance remaining by one.
From here, we check to see if the sprite has moved out of bounds; if it has, it is the placed back in bounds. The boundaries basically are the top half of the screen. This ensures that enemy ships stay on the top half and the player says on the bottom, so we don't have to worry about collision detection between ships.
The last part of our AI component is firing. A random number is selected between 1 and 60; if the number 1 is selected the enemy ship fires. Firing is done by adding another enemy bomb sprite to the ebomb group. The new bomb is passed its starting location, which is the horizontal center and the bottom of the enemy ship. Last but certainly not least, the shot sound effect is played.
def update(self): #movement if self.distance == 0: #random distance from 3 to 15 turns self.distance=random.randint(3,15) #random x,y velocity form -2 to 2 self.x_velocity=random.randint(-2,2) self.y_velocity=random.randint(-2,2) self.rect.move_ip((self.x_velocity, self.y_velocity)) self.distance -= 1 if self.rect.left < 0: self.rect.left = 0 elif self.rect.right > 640: self.rect.right = 640 if self.rect.top <= 0: self.rect.top = 0 elif self.rect.bottom >= 220: self.rect.bottom = 220 #random 1 - 60 determines if firing fire=random.randint(1,60) if fire == 1: ebomb_sprites.add(Ebomb(self.rect.midbottom)) shot1.play()
Seeing as we are in the process of defining our sprites, we might as well create the last two. The bomb and enemy bomb classes are similar and probably could have been combined into one class. I decided to keep them separate for the sake of simplicity and clarity, which I felt to be more important aspects of this tutorial. In both classes the sprite initalizer is called, the appropriate image is loaded and the start position set. In the update method, we check if the player's bomb has gone off the top of the screen, and in the case of the enemy bomb, we see if it has gone off the bottom. If the bomb has gone off screen, the sprite deletes itself with the kill method. If the bomb still is on screen, it is moved four pixels up or down, respectively.
class bomb(pygame.sprite.Sprite): """This class is for the players weapons""" def __init__(self, startpos): pygame.sprite.Sprite.__init__(self) #call Sprite intializer self.image, self.rect = load_image('fire.bmp', -1) self.rect.center = startpos def update(self): if self.rect.bottom <= 0: self.kill() else: self.rect.move_ip((0, -4)) class Ebomb(pygame.sprite.Sprite): """This class is for the players weapons""" def __init__(self, startpos): pygame.sprite.Sprite.__init__(self) #call Sprite intializer self.image, self.rect = load_image('efire.bmp', -1) self.rect.midtop = startpos def update(self): if self.rect.bottom >= 480: self.kill() else: self.rect.move_ip((0, 4))
Let's get back to our main function. We now can create our game loop, but before we do, we must create two variables. The first one, running, is used as the condition for the while loop. To end the loop and thus exit the game, all we have to do is set the running value to zero. The other variable, counter, is used to see if it is time to add another enemy ship to the game. We then start the loop and add a delay of ten milliseconds to make sure that the game doesn't run too quickly. If you are using an older computer, the delay might be too long and should be lowered accordingly.
running = 1 counter = 0 while running: pygame.time.delay(10)
The first thing we want to do in our loop is check for and cycle through any user events. The QUIT can be created many different ways, such as clicking the close button on the window frame if you're not playing in full-screen mode. All the other events come from the keyboard and are pretty self explanatory. In the case of the user pressing the f key, a new bomb sprite is added to the player's bomb group. The number of shots is increased by one, and this information is part of the game stats printed to the terminal window at the end of the game. We then play the shot sound affect.
for event in pygame.event.get(): if event.type == QUIT: running = 0 elif event.type == KEYDOWN: if event.key == K_ESCAPE: running = 0 elif event.key == K_LEFT: ship.x_velocity = -2 elif event.key == K_RIGHT: ship.x_velocity = 2 elif event.key == K_UP: ship.y_velocity = -2 elif event.key == K_DOWN: ship.y_velocity = 2 elif event.key == K_f: bomb_sprites.add(bomb(ship.rect.midtop)) numberof_shots += 1 shot2.play() elif event.type == KEYUP: if event.key == K_LEFT: ship.x_velocity = 0 elif event.key == K_RIGHT: ship.x_velocity = 0 elif event.key == K_UP: ship.y_velocity = 0 elif event.key == K_DOWN: ship.y_velocity = 0
To causes the game to last longer and encourage the player to work quickly, every 200 times through the loop we add another enemy ship at the center of the top half of the screen as shown below.
counter += 1 if counter >= 200: enemyship_sprites.add(Enemy(320)) counter = 0
Clearing all the sprites from the screen is easy with the clear method, because our sprite groups are of the type RenderClear. The clear method takes two arguments. The first is the surface from which you want to clear the sprites, and the second argument is the surface that should be used as the background.
ebomb_sprites.clear(screen, background_image) enemyship_sprites.clear(screen, background_image) bomb_sprites.clear(screen, background_image) playership_sprite.clear(screen, background_image)
We call the update method on all of our sprite groups, which in turn calls the update method of each sprite in that group. As we recall, the update method moves all of our sprites and makes any AI decisions required.
bomb_sprites.update() playership_sprite.update() ebomb_sprites.update() enemyship_sprites.update()
Now that all of our sprites have been moved to their new positions, we can check for collisions between the player's bombs and enemy sprites, as well as for collisions between enemy bombs and the player's ship.
The group collidemethod returns a dictionary of all sprites in the first group that collide with sprites from the second group. The indices of the dictionary are the sprites in the first group that collide, and the value is a list of sprites which which they collides. The group collidemethod take four arguments. The first two are the groups you want to check to see if they have colliding sprites, and the last two arguments are used if you want the colliding sprites to be deleted. For our example, we want both the player's bomb and the enemy it collided with to be destroyed, so the last two values are 1 and 1. If you didn't want either of the sprites to be deleted, set their corresponding values to 0.
For every collision between the players bombs and an enemy ship, we play the explode sound effect and increase the enemy-killed stat by one. Next, we check if the enemy sprite group is empty. If it is, we print a message to the terminal that includes the game stats and sets the running variable to zero so that the game loop exits on the next iteration through the game loop.
#See if players bombs hit any enemy ships for hit in pygame.sprite.groupcollide(enemyship_sprites, bomb_sprites, 1, 1): explode1.play() enemy_killed += 1 if enemyship_sprites.sprites() == : print "You Win!!!!" print "Shot fired:",numberof_shots print "Hits taken:",numberof_hits print "Enemy killed", enemy_killed running = 0;
Detecting collisions between enemy bombs is accomplished through a similar process. One important thing to note here is we automatically delete enemy bombs that collide, but we do not delete the player's ship; hence, the last parameter in the group collide method is a 0. For every hit we increase the number of hits by one and play our explode sound effect. If the number of hits is three or more, we print a message to the terminal, and we set the running variable to zero.
for hit in pygame.sprite.groupcollide(ebomb_sprites, playership_sprite, 1, 0).keys(): numberof_hits += 1 explode2.play() if numberof_hits >= 3: print "You lose :(" print "Shot fired:",numberof_shots print "Hits taken:",numberof_hits print "Enemy killed", enemy_killed running = 0;
Now that we have gotten rid of all the sprites that should be removed from game play, we can redraw the screen. Calling the draw method on each of the sprite groups draws the sprites in that group to the specified surface, which in our case is the screen. The display.flip method is used with double buffing. Double buffering is where all drawing is done to a hidden buffer and then the hidden buffer is swapped with the draw buffer. This increases drawing speeds, as unseen layers need not be drawn to the screen. If double buffering is not supported by your hardware, drivers and so on, it can be simulated by Pygame.
ebomb_sprites.draw(screen) bomb_sprites.draw(screen) enemyship_sprites.draw(screen) playership_sprite.draw(screen) pygame.display.flip()
The last part of our main function, which is outside of our loop, is to pause three seconds before exiting so that the player can see the final screen. I also reset the screen so it is in a window. Doing so is not necessary, but I have had some problems with certain development environments, most notably IDLE, and resetting the screen helps in avoiding some small problems.
pygame.time.delay(3000) screen = pygame.display.set_mode((640, 480))