Improve your Python programming skills by creating a simple computer game! In this workshop you will be introduced to Pygame, a free and open-source library for creating computer games in Python. Specifically, you will create a simple “Frogger” style game that will introduce you to the following concepts: drawing graphics to the screen, capturing keyboard input and representing movement, creating basic collision detection, increasing difficulty, and implementing game loops with win/lose conditions.
By default, Pygame is not included with Python installations. For this workshop, we'll assume that you've already installed the latest version of Python on your machine (or at least Python 3x).
Next, we'll need to install Pygame via pip
in the command line or terminal. Open up your command prompt and type the following command:
python3 -m pip install -U pygame --user
The command should automatically install the latest version of Pygame on your machine. Note: If you happen to have Pygame already installed, you can upgrade to the latest version with the --upgrade
flag:
pip install pygame --upgrade
(If the upgrade command shows a ModuleNotFoundError, you’ll know Pygame is not currently installed.)
Please see the official installation instructions if you run into any difficulties installing Pygame.
If you are curious about any of the functions or tools that we will be exploring, I highly recommend checking the Pygame docs. Pygame has very thorough documentation and the docs are an invaluable resource for understanding the possibilities open to you as a game designer.
UPDATE: As of the time of writing this, the developers of the Pygame website have decided to temporarily shut down the site to show support/solidarity with Ukraine. Unfortunately, this means you currently can't access the documentation online. You can, however, generate the Pygame docs locally by writing the following in your terminal/command prompt:
python -m pygame.docs
In this workshop, you'll learn to:
- implement and understand the Pygame library
- utilize an object-oriented programming approach
- draw and manipulate graphics on the screen
- handle keyboard input
- work with game loops and win/lose conditions
So, without further ado, let's get started!
The first thing we'll need to do is create our game screen, where all of the action will take place. Let's create a series of constant global variables to define the height, width, and title of the screen, along with some base colors to use:
# import library
import pygame
# initialize pygame
pygame.init()
# size of our screen and caption
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 800
SCREEN_TITLE = "Froggish"
# screen color in RGB
WHITE_COLOR = (255, 255, 255)
BLACK_COLOR = (0, 0, 0)
First we import
the Pygame library, and initialize (init()
) it so we can use it in our project. Next, we create our variables for the screen size (800x800, so it doesn't fill our entire computer screen but still gives us a lot of room to play with) along with giving our window a title. Next, we create two constants for a basic black and white color. In Pygame, color is determined by RGB value (e.g., 255, 255, 255 is white). Because we don’t want any of these variables to change, we make them all constants (indicated by the all-caps).
Next, we'll need to create a "clock" object that will allow us to control the framerate or FPS of the game, which is the speed at which we refresh the display. For most games, 60 FPS is the standard, so we’ll set the clock’s "tick" rate to a constant 60. We'll also want to set up a font we can use for displaying text:
# clock to handle FPS
clock = pygame.time.Clock()
TICK_RATE = 60
# font set-up
pygame.font.init()
# you can load in your own fonts, but SysFont below allows you to access fonts already installed on your machine
font = pygame.font.SysFont('comicsans', 75)
After creating our clock and setting the tick rate, we initialize our Pygame font and set it to ComicSans (or whatever you'd like) with a size of 75. We'll use this font when we want to present text to the player (such as a "game over" message).
Let's get right into creating our Game
class, which will handle all the basic logic for running the game. First, we'll create our game screen now that we have the initial parameters we need:
class Game:
def __init__(self, image_path, title, width, height):
self.title = title
self.width = width
self.height = height
# create window of specified size
self.game_screen = pygame.display.set_mode((width, height))
# set the game window color to white
self.game_screen.fill(WHITE_COLOR)
pygame.display.set_caption(title)
background_image = pygame.image.load(image_path)
self.scaled_bkg = pygame.transform.scale(background_image, (width, height))
Let's break this code down. First, we initialize our Game
class with a few starting parameters: self
, image_path
, title
, width
, and height
. self
simply allows us to utilize our variables throughout the class's methods (which we'll need to create soon). image_path
will pass in the background image we want for our game. title
, width
, and height
are the parameters we'll use to designate the size of our game screen.
We create our game screen itself by using the pygame.display.set_mode()
function. Our game_screen
variable represents a Pygame surface, which you can think of like a canvas on which you draw graphics and text. In the pygame.display.set_mode()
function, we will pass an argument for the display resolution, which is our two variables representing width and height in a tuple. Note that the tuple is contained within the function’s parentheses, so there are double parentheses in the function above.
To give our screen a basic fill color, we use the fill()
function, using our WHITE_COLOR
. Next, we set our window caption with the set_caption()
function, which will be the title
that we pass in when we create a new Game
object.
Lastly, we'll load()
in the background image we want to use for our game. This will be passed in as the parameter image_path
. We then use the scale()
function to resize the image so that it fits our game screen window, and set it to a new scaled_bkg
variable.
Now that we have our basic initializer set-up for our Game
class, let's go ahead and create a new class method that will allow us to run our game.
Our runGame()
method will control the main game loop for our program. This means we want our game to continue running until a certain condition (game over) is met. Let's create a looping mechanism that will do just that:
def runGame(self):
isGameOver = False
while not isGameOver:
for event in pygame.event.get():
if event.type == pygame.QUIT: # if event type is a quit event
isGameOver = True
print(event)
# update the graphics
pygame.display.update()
# tick our clock and render next frame
clock.tick(TICK_RATE)
# fill the screen background
self.game_screen.fill(WHITE_COLOR)
pygame.quit()
quit()
First, we create a new variable for tracking our game over condition, aptly named isGameOver
, which we initially declare as False
. Next, we create a while
loop that will continue to run until isGameOver
is set to True
.
Within the while
loop, we use a for
loop to cycle through events
. (When working with while
loops, always watch your indentation closely so you don’t accidentally create an infinite loop.) To log user input (mouse placement, key presses, closing the window, etc.), we use the pygame.event.get()
function. This function creates a queue of all events and allows the program to respond accordingly. To control our game over condition, we check if the built-in quit
event (meaning the user has closed the game screen window) has occurred. We also print all events to the console so we can monitor the events unfolding. We also update()
our game display and tick our clock
each iteration of the loop.
Lastly, we set our game_screen
to have a basic white fill. We then quit
out of Pygame (outside of the while
loop).
Before we can test to see if our code is working, we need to create a new Game
object and call our runGame()
function. So, outside of our Game
class, write the following lines:
new_game = Game('frogger_bkg.png', SCREEN_TITLE, SCREEN_WIDTH, SCREEN_HEIGHT)
new_game.runGame()
We first pass in our background image. There will be some additional steps we'll need to accomplish before the background actually displays in our game window, but we can at least pass in the image for now so we're prepared. We then pass in the remaining title and size parameters, and finally call our runGame()
method.
If you run your script, you should now see an empty white screen display. If you move your mouse around, you can see all of the events being tracked in the console. If you click the X button to close the window, you should then see a "Quit" event being registered:
Next, let's start adding some objects to our game environment.
We will be using our own custom images in this game, but first let's just demonstrate on a basic level how drawing functions work in Pygame.
Let's say we wanted to draw a simple rectangle and circle to the screen. In the while
loop, add the following lines:
# demonstrates drawing
pygame.draw.rect(self.game_screen, BLACK_COLOR, (350, 350, 100, 100)) # surface, color, (x, y, width, height)
pygame.draw.circle(self.game_screen, BLACK_COLOR, (400, 300), 50)
To draw our rectangle, we’ll use the draw.rect()
function. This function requires three main parameters: the surface to draw on, the color of the rectangle, and the rectangle object (specifying the left position, the upper position, the width, and the height). The draw.circle()
function similarly takes a surface, a color, a center position, and a radius. The current values will create a rectangle in the center of the screen, with a circle aligned on top of it.
As mentioned, we will be using our own images, so you can go ahead and delete or comment out these two lines of drawing code. This was meant for demonstration purposes so you can see how drawing in Pygame works more generally.
Let's begin actual development by creating a new class for our game objects.
Because we will be creating several different types of objects (a player object, enemies, and treasure), let's create a parent object class from which we can derive each of the other classes. This GameObjects
class will house all of the basic attributes and functionality that each object shares in common with the others. Outside of our Game
class, create a new class called GameObjects
:
class GameObjects:
def __init__(self, image_path, x, y, width, height):
self.x_pos = x
self.y_pos = y
self.width = width
self.height = height
# get each object's image and scale it
object_image = pygame.image.load(image_path)
self.image = pygame.transform.scale(object_image, (width, height))
def draw(self, background):
background.blit(self.image, (self.x_pos, self.y_pos))
Because every object will have a unique image, an x and y position, and a width and height, we first define these as attributes in our initializer. Next, we load each object's image into the object_image
variable, and scale it to make our objects' sizes relatively uniform. Lastly, we create a new draw()
method that will allow us to draw each object's image over our game's background. To draw images in Pygame, we use the blit()
function, which allows us to draw an image on top of another image (a surface).
Now that we have our basic attributes set up for our game objects, we can begin to create the objects themselves. First, let's create a class for our player-character. We know we want our character to be able to move around the screen, so let's include that functionality into the class first.
To start, we need to add a line of code to our game loop in the runGame()
method.
while not isGameOver:
for event in pygame.event.get():
if event.type == pygame.QUIT: # if event type is a quit event
isGameOver = True
# detect when key is pressed
direction = pygame.key.get_pressed()
print(event)
In our for
loop, we create a new variable, direction
, to log which keys are being pressed on the keyboard with the get_pressed()
function. We can use this variable to record which direction the player wants to go and move their character accordingly.
Next, we'll create a new class for our PlayerCharacter
down below (and outside of) the GameObjects
class we created earlier:
class PlayerCharacter(GameObjects):
SPEED = 10
def __init__(self, image_path, x, y, width, height):
super().__init__(image_path, x, y, width, height)
def move(self, direction, max_height):
# move player based on direction pressed
if direction[pygame.K_UP]:
self.y_pos -= self.SPEED
elif direction[pygame.K_DOWN]:
self.y_pos += self.SPEED
elif direction[pygame.K_LEFT]:
self.x_pos -= self.SPEED
elif direction[pygame.K_RIGHT]:
self.x_pos += self.SPEED
# prevent player from moving below the screen (subtracting the image's height from the screen height)
if self.y_pos >= max_height - self.height:
self.y_pos = max_height - self.height
As you can see, we include the GameObjects
class in our PlayerCharacter
class declaration to indicate that PlayerCharacter
is a subclass of GameObjects
.
Next, we create a new variable SPEED
, which will allow us to control how fast the player can move their character. To be able to have access to the rest of the variables in the GameObjects
class, we then use a technique called inheritance with the super()
function. super()
allows us to utilize all the attributes and methods of our parent class (GameObjects
). For each new kind of object we create (e.g., the player-character, enemies, treasure, etc.), we can use super()
to inherit all of the attributes and methods of the parent class so we don't have to rewrite them each time.
To move our character around, we also create a new move()
method specific to our player object. We take in two parameters: the direction
variable we created above to track our player's movements, and a max_height
parameter (which will be the maximum y
size of our screen, 800) that we'll use to apply a boundary at the bottom of the screen.
To get which the direction the player wants to go, we use a series of if elif
statements, along with Pygame's built-in key constants. For each press, we move the character by adding or subtracting their current position by the speed (10) we have set.
If we were to run the game now, you'd notice you can move your character beyond the bounds of the game screen. Obviously, this is something we'd want to correct. As an example of how this can be done, we created a boundary at the bottom of the screen in the last lines of code above. This simple if
statement checks the player's position, and using the height of the character's image, determines if they are about to cross the bottom of the screen. If they are, we reset their position so that they can't drift out of bounds.
Now that we have some basic functionality for our player-character object, let's go ahead and add it to our game and render it to the screen. To do so, we first need to create an instance of the PlayerCharacter
class. In the runGame()
method, underneath the isGameOver
variable declaration, add the following:
player_character = PlayerCharacter('frog.png', 375, 700, 50, 50)
Here we create a new player_character
object, using our frog image, and supplying the rest of the parameters we defined for the class (x
, y
, width
, and height
).
Next, let's use our draw()
and move
methods. In the while
loop of our runGame()
method, add the following lines of code (below the game screen fill
function):
player_character.move(direction, self.height)
player_character.draw(self.game_screen)
Based on the direction of your key press, your character should now be able to traverse the game screen, and you should also be unable to move below the bottom boundary of the screen. As a challenge for later on, I'd encourage you to attempt the other three sides of the screen on your own!
Now that we have our player object ready to go, we should create some enemies for the player to contend with. Like we did with the PlayerCharacter
class, let's create a new class for enemies, also keeping it a subclass of GameObjects
.
class EnemyCharacter(GameObjects):
SPEED = 5
def __init__(self, image_path, x, y, width, height):
super().__init__(image_path, x, y, width, height)
self.image_path = image_path
def move(self, max_width):
if self.x_pos <= 20:
self.SPEED = abs(self.SPEED)
elif self.x_pos >= max_width - (20 + self.width):
self.SPEED = -abs(self.SPEED)
self.x_pos += self.SPEED
Because we want our enemies to move around, we also need to give them a speed. We'll set it at 5 for now. Next, we set up our initializer, again inheriting from the parent class using super()
.
Next, we write the logic for the enemy movement itself in a new method move()
. Because we also don't want our enemies to move off screen, we pass in max_width
as a parameter. Ultimately, we'd like our enemies to move both the the left and to the right of the screen. So, we check if the enemies x-position is 20 pixels to the right of the left-hand side of the screen, and if so, set the constant SPEED
to a positive value using the abs()
function (meaning the enemy will move right). Otherwise, if the enemy reaches the right-hand side of the screen, we set speed to a negative value (meaning the enemy will move left). Finally, we add whatever value the speed is to the x-position, effectively moving the enemy back and forth across the screen.
Next, we will need to instantiate a new enemy object. In our runGame()
method, just below our player_character
object creation, create a new enemy object towards the bottom of the screen:
enemy_0 = EnemyCharacter('enemy.png', 20, 600, 50, 50)
We'll want multiple enemies, so we'll call this one enemy_0
. All that's left now is draw and move the enemy. We can add this in our while
game loop, below our player_character
moving and drawing:
enemy_0.move(self.width)
enemy_0.draw(self.game_screen)
And that's it! If you now run your game, you should see an enemy moving back and forth across the screen.
Currently, we can't do much interacting with the enemy, so let's set up collision detection.
Since our player character will be the focal point of the collisions, let's set up a basic collision detection method in our PlayerCharacter
class.
Below our move()
method, add the following new method detect_collision()
:
def detect_collision(self, other_body):
# if below enemy
if self.y_pos > other_body.y_pos + other_body.height:
return False
# if above enemy
elif self.y_pos + self.height < other_body.y_pos:
return False
# if we're to the right of enemy
if self.x_pos > other_body.x_pos + other_body.width:
return False
# if we're to the left of enemy
elif self.x_pos + self.width < other_body.x_pos:
return False
return True
Since we'll not only want to interact with enemies but also (eventually) with treasure objects, we'll use the other_body
parameter as a catchall for objects' interactions/collisions with the player. Using a series of if elif
statements, we check whether our player object is below, above, to the right, or to the left of an enemy. We do this by checking the relative position of our player object to that of the enemy and take into account the width/height of each object to get more accurate detection.
Next, we'll need to call this method in our while
loop. Let's use an if
statement to check if the player has collided with the enemy, and if so, display a short message saying "Game Over":
if player_character.detect_collision(enemy_0):
isGameOver = True
text = font.render('Game Over :(', True, BLACK_COLOR)
self.game_screen.blit(text, (200, 350))
pygame.display.update()
clock.tick(1)
break
If the player has indeed collided with the enemy, we'll set isGameOver
to True
so we exit the loop. We also create a new text
variable, using the font.render()
function to contain our game over message in black. (The True
we've added is part of the argument indicating whether we want the text to be anti-aliased or not. Anti-aliasing allows us to smooth the text if it has jagged edges.) We then blit()
that text towards the middle of the screen. Lastly, we update the display, tick our clock, and break out of the loop (this break
will become important later, once we add collisions with other objects).
Now that we have some basic functionality for our game, let's go ahead and add our background image.
Since we have our preliminary drawing and scaled background image set up, we can do this quite easily, with one line of code. In our while
loop, just below where will fill()
the game screen with white, add the following line:
self.game_screen.blit(self.scaled_bkg, (0, 0))
We blit()
our scaled background image to the screen at position 0,0. If you run the game now, you should see the background image appear!
Currently, we have a way for a player to lose the game, but not to win. Let's add that functionality in now. To do so, we can create a new treasure object. If the player reaches the treasure, we'll say that they win the game (or at least, that round of the game).
To do so, let's create a new treasure object as part of the GameObjects
class. In our runGame()
function, underneath our other object instantiations, add a new treasure object like so:
treasure = GameObjects('treasure.png', 375, 50, 50, 50)
We'll set the x and y position to 375, 50, respectively, so it is near the top of the screen, and set the width and height to 50 each. Next, we'll need to draw the object. In our main game loop, below where we draw the player and the enemy, add the following drawing function:
treasure.draw(self.game_screen)
If you run the game, you should now see the treasure displayed at the top of the screen. Like we did with our enemies, we also want some logic for when a player collides with it. Let's modify our collision detection code in our while
loop:
if player_character.detect_collision(enemy_0):
isGameOver = True
text = font.render('Game Over :(', True, BLACK_COLOR)
self.game_screen.blit(text, (200, 350))
pygame.display.update()
clock.tick(1)
break
elif player_character.detect_collision(treasure):
isGameOver = True
text = font.render('You won :D', True, BLACK_COLOR)
self.game_screen.blit(text, (200, 350))
pygame.display.update()
clock.tick(1)
break
In addition to colliding with an enemy, we can also now collide with the treasure chest. If you run the game now and make it to the treasure, you should see the "You Won" message displayed to the screen.
Currently, the game ends on the very first round, whether you win or lose. This is not very exciting, so let's add a bit more complexity. We can add levels so that each time the player successfully makes it to the treasure, they can move on to the next, more difficult round.
Let's set this up so that each time the player makes it to the treasure, they move to the next round. Each round, we can increase the difficulty by increasing the speed of the enemies' movements and increasing the amount of enemies on the screen to avoid.
To accomplish this, let's create a new variable level_speed
that will allow us to control both the current level, as well as the current speed of the enemies. We'll set this as a new parameter in our runGame()
method:
def runGame(self, level_speed):
isGameOver = False
did_win = False
Now when we call our runGame()
method at the very bottom of the program, we can supply it with a level to start from. In the method call, supply it with the parameter 1, like so:
new_game.runGame(1)
You'll notice in the runGame()
method above that we have added a new variable: did_win
. We'll use this variable to determine whether or not the player actually made it to the chest, and if they did, we'll advance to the next level.
So, let's add this variable to our collision detection logic:
if player_character.detect_collision(enemy_0):
isGameOver = True
did_win = False # did they win?
text = font.render('Game Over :(', True, BLACK_COLOR)
self.game_screen.blit(text, (200, 350))
pygame.display.update()
clock.tick(1)
break
elif player_character.detect_collision(treasure):
isGameOver = True
did_win = True # did they win?
text = font.render('You won :D', True, BLACK_COLOR)
self.game_screen.blit(text, (200, 350))
pygame.display.update()
clock.tick(1)
break
You can see that now, if the player collides with an enemy, did_win
is False
, but if they collide with the treasure, did_win
is True
.
Next, to advance the level, let's delete our original pygame.quit()
and quit()
statements, since we don't want the game to end if they successfully get the treasure. In its place, add the following code:
if did_win:
self.runGame(level_speed + 0.5)
else:
return
Now, if the player does win the round, we will call the runGame()
method again, but increase the level_speed
by .5. This will advance our level by .5 each successful round. Next, we'll want to increase the speed of our enemy by using this variable.
In our runGame()
method, just below where we create our enemy_0
object, add the following line:
enemy_0.SPEED *= level_speed
If you run the game now, you'll notice that every time you make it to the treasure, the game resets, and the enemy gets a little quicker each time.
It's nice that our enemy moves more quickly as we progress, but it would be better if we were also spawning new enemies for the player to contend with. As a final step for our game, let's create two more enemies that will spawn as we advance through the levels.
Below our first enemy creation in the runGame()
method, let's add two more:
enemy_1 = EnemyCharacter('enemy.png', self.width - 40, 400, 50, 50)
enemy_1.SPEED *= level_speed
enemy_2 = EnemyCharacter('enemy.png', 20, 200, 50, 50)
enemy_2.SPEED *= level_speed
We'll spawn each of these enemies a bit higher up on the screen, so that our enemies are not all in the same place.
Next, we'll need to draw and move these enemies like we did with our first enemy. However, in this case, we only want to spawn these enemies after the player has reached a certain level. Below our first enemy_0
move()
and draw()
calls in the while
loop, add the following:
# add more enemies based on current level
if level_speed > 2:
enemy_1.move(self.width)
enemy_1.draw(self.game_screen)
# add more enemies based on current level
if level_speed > 4:
enemy_2.move(self.width)
enemy_2.draw(self.game_screen)
Now these enemies will only draw and move if the player has reached a certain level.
Lastly, we still need to set up collision with these enemies. We can do this by simply modifying the first line of our if
statement, like so:
if player_character.detect_collision(enemy_0) or player_character.detect_collision(enemy_1) or player_character.detect_collision(enemy_2):
Now our statement also checks for collisions with the two new enemies we've created. If you run the game and collide with the new enemies, you should now receive "Game Over."
Note: Using or
in our if
statements is a rather crude way to go about this. If we had 100 different enemies, for instance, we'd have to make an or
check for each of them, which is unwieldy. A better way of going about it might be to create an array that contains all enemy objects, and utilize checks of the array for each object. However, to keep things simple for our basic game (considering we only have three enemies), this method will suffice.
Well done! You have just created your first computer game in Python. Although the game is very basic, I hope you can think of ways to apply these skills further in creating your own games, or at least, in making the one we just made together more interesting. As further practice, you might attempt the following challenges to improve the Froggish game:
- Block the player from escaping the other three sides of the screen (recall that we did the bottom side together!).
- Spawn the treasure chest in random locations each level.
- Make enemies move up and down, in addition to left and right. Add randomness to their movements to make it more tricky for the player to guess where they are headed. Alternatively, you could have them attempt to follow the player, by tracking the player's position relative to their own.
- Create "power-up" objects that can give your frog a speed boost, or temporary invincibility, or other perks.
- Create a projectile that the player can shoot at the enemies to destroy them.