In a previous tutorial we introduced graphical game development with PyGame, covering how to develop a 2D game with animated sprites and user interaction. In this tutorial, we'll go a step further and create a 2D platformer, where you can have an alien walk and jump around a room full of boxes. The previous PyGame tutorial is not a prerequisite for trying this one.
We're going to focus on basic animation and movement to create a solid base from which you can continue on to build an entire platform game, complete with enemies, power-ups and multiple levels.
Getting Started
This game is built on Repl.it. If you do not have an account yet, head over to the sign up page to get started.
Create a new repl and select "PyGame" from the language dropdown.
You'll see "Python3 with PyGame" displayed in the default console and a separate pane in the Repl.it IDE where you will be able to see and interact with the game you will create.
Before we start writing code, we're going to need a few sprites, which we've made available here. Extract this ZIP file and add the files inside to your repl using the upload file
function. You can select multiple files to upload at once. Your repl's file pane should now look like this:
In this tutorial, we will be gradually building up the main.py
file, adding code in different parts of the file as we go along. Each code snippets will contain some existing code to give you an idea of where in the file the new additions should be placed. The line # ...
will be used to represent existing code that has been left out for brevity.
Setting up the scaffolding
We will start with the following code in main.py
, which draws a black screen:
import pygame
WIDTH = 400
HEIGHT = 300
BACKGROUND = (0, 0, 0)
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
clock = pygame.time.Clock()
while True:
screen.fill(BACKGROUND)
pygame.display.flip()
clock.tick(60)
if __name__ == "__main__":
main()
At the top of the file, we import pygame
. Following that, we set the width and height of the screen in pixels, and the background color. This last value is an RGB tuple, and will make our background black. To use a white background instead, we would write (255, 255, 255)
.
Then, in the main
method, we initiate PyGame, create both the screen and the clock, and start the game loop, which is this code:
while True:
screen.fill(BACKGROUND)
pygame.display.flip()
clock.tick(60)
The game loop is where everything happens. Because our game runs in real time, our code needs to constantly poll for the user's keystrokes and mouse movements, and constantly redraw the screen in response to those keystrokes and mouse movements, and to other events in the game. We achieve this with an infinite while loop. PyGame uses the final clock.tick(60)
line in this loop to adjust the game's framerate in line with how long each iteration of the loop takes, in order to keep the game running smoothly.
Now let's draw something on this black screen. Our game is going to have two sprites: an alien, which will be the player, and a box. To avoid code duplication, let's create a Sprite
parent class before we create either of those. This class will inherit from the pygame.sprite.Sprite
class, which gives us useful methods for collision detection β this will become important later on.
class Sprite(pygame.sprite.Sprite):
def __init__(self, image, startx, starty):
super().__init__()
self.image = pygame.image.load(image)
self.rect = self.image.get_rect()
self.rect.center = [startx, starty]
def update(self):
pass
def draw(self, screen):
screen.blit(self.image, self.rect)
def main():
As this class will be the parent for all other objects in our game, we're keeping it quite simple. It has three methods:
-
__init__
, which will create the sprite with a given image and a PyGame rectangle based on that image. This rectangle will initially be placed at the position specified bystartx
andstarty
. The sprite's rectangle is what PyGame will use for sprite movement and collision detection. -
update
, which we'll use in child classes to handle events, such as key presses, gravity and collisions. -
draw
, which we use to draw the sprite. We do this by blitting it onto the screen.
Now we can create our Player
and Box
objects as child classes of Sprite
:
class Sprite(pygame.sprite.Sprite):
# ...
class Player(Sprite):
def __init__(self, startx, starty):
super().__init__("p1_front.png", startx, starty)
class Box(Sprite):
def __init__(self, startx, starty):
super().__init__("boxAlt.png", startx, starty)
def main():
We'll add more code to the player later, but first let's draw these sprites on the screen.
Drawing the sprites
Let's go back to our main
function and create our sprites. We'll start with the player:
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
clock = pygame.time.Clock()
player = Player(100, 200)
Then we need to put boxes under the player's feet. As we will be placing multiple sprites, we'll create a PyGame sprite group to put them in.
player = Player(100, 200)
boxes = pygame.sprite.Group()
Our box sprites are 70 pixels wide, and we need to span over the screen width of 400 pixels. We can do this in a for
loop using Python's range
:
player = Player(100, 200)
boxes = pygame.sprite.Group()
for bx in range(0,400,70):
boxes.add(Box(bx,300))
Now we need to go back to the game loop and add some code to make things happen. First, we'll have PyGame put new events on the event queue, and then we'll call the player's update
function. This function will handle the events generated by pygame.event.pump()
.
while True:
pygame.event.pump()
player.update()
# ...
For a more complex game, we would want to loop through a number of sprites and call each one's update
method, but for now just doing this with the player is sufficient. Our boxes won't have any dynamic behavior, so there's no need to call their update
methods.
In contrast to update
, we need all our sprites to draw themselves. After drawing the background, we'll add a call to the player's draw method. To draw the boxes, we can call PyGame's Group.draw
on our boxes
group.
while True:
pygame.event.pump()
player.update()
# Draw loop
screen.fill(BACKGROUND)
player.draw(screen)
boxes.draw(screen)
pygame.display.flip()
clock.tick(60)
Our game loop is now set up to update and draw every sprite in the game in each cycle of the game loop. If you run the game now, you should see both the player and the line of boxes on the screen.
Next, we're going to add some user interaction.
Making the Player Walk
Let's return to the Player
object and make it mobile. We'll move the player using pygame.Rect.move_ip
, which moves a given rectangle by a given vector. This will be wrapped in a move
method, to simplify our code. Create this method now:
class Player(Sprite):
# ...
def move(self, x, y):
self.rect.move_ip([x,y])
Now that we have a way to move the player, it's time to add an update
method so that this movement can be triggered by key presses. Add an empty update
method now:
class Player(Sprite):
def __init__(self, startx, starty):
super().__init__("p1_front.png", startx, starty)
def update(self):
pass
def move(self, x, y):
self.rect.move_ip([x,y])
PyGame provides a couple of different ways to check the state of the keyboard. By default, its event queue collects KEY_DOWN
and KEY_UP
events when particular keys are pressed and released. Using a KEY_DOWN
event to move the player seems like the logical thing to do, but because the event is only triggered in same update loop in which the key is first pressed, this would force us to rapidly tap an arrow key to keep moving in a single direction.
We need a way to move the player whenever an arrow key is held down, not just after it's pressed. So instead of relying on events, we will query the current status of all keyboard keys with pygame.key.get_pressed()
:
def update(self):
# check keys
key = pygame.key.get_pressed()
This method returns a tuple of 0s and 1s showing the pressed status of each key on the keyboard. We can thus detect whether the left or right arrow key is currently pressed by indexing the tuple with PyGame's keyboard constants, like so:
def update(self):
# check keys
key = pygame.key.get_pressed()
if key[pygame.K_LEFT]:
self.move(-1,0)
elif key[pygame.K_RIGHT]:
self.move(1,0)
Run the game. You should now be able to move the player left and right, albeit very slowly. Let's speed things up and reduce our code's reliance on magic numbers at the same time by giving the player a speed
variable.
class Player(Sprite):
def __init__(self, startx, starty):
super().__init__("p1_front.png", startx, starty)
self.speed = 4
def update(self):
# check keys
key = pygame.key.get_pressed()
if key[pygame.K_LEFT]:
self.move(-self.speed,0)
elif key[pygame.K_RIGHT]:
self.move(self.speed,0)
Right now the player glides from side to side, but we have already uploaded images for a walk cycle animation, so let's implement that now. First, we'll add some image loading code to our player's __init__
method:
def __init__(self, startx, starty):
super().__init__("p1_front.png", startx, starty)
self.stand_image = self.image
self.walk_cycle = [pygame.image.load(f"p1_walk{i:0>2}.png") for i in range(1,12)]
self.animation_index = 0
self.facing_left = False
self.speed = 4
In this code, we first designate our initial alien image as stand_image
. This will allow us to use it for the player when he's standing still. We then load our walking images into a list called walk_cycle
, using Python's string formatting to get the correct filename format (p1_walk01.png
-> p1_walk11.png
). We then create self.animation_index
, which will record which frame of the walk cycle the player is on, and self.facing_left
which will help us to flip the right-facing walking images when the player is walking left.
Now let's implement the actual animation. Create a new method called walk_animation
:
class Player(Sprite):
# ...
def walk_animation(self):
self.image = self.walk_cycle[self.animation_index]
if self.facing_left:
self.image = pygame.transform.flip(self.image, True, False)
if self.animation_index < len(self.walk_cycle)-1:
self.animation_index += 1
else:
self.animation_index = 0
Here we're setting the player's current image to the frame of the walk cycle we're currently on. If the player is facing left, we use pygame.transform.flip
to horizontally flip his sprite (the last two arguments are for horizontal and vertical flipping, respectively). Then we animate the player by incrementing the animation_index
, unless the animation is in its penultimate frame, in which case we return to the start of the animation.
Let's add this to our update
method now:
def update(self):
# ...
# check keys
key = pygame.key.get_pressed()
if key[pygame.K_LEFT]:
self.facing_left = True
self.walk_animation()
self.move(-self.speed,0)
elif key[pygame.K_RIGHT]:
self.facing_left = False
self.walk_animation()
self.move(self.speed,0)
else:
self.image = self.stand_image
If we're moving left or right, we set self.facing_left
appropriately and call self.walk_animation
. Otherwise, we set the player's image to self.stand_image
.
Run the game now to see the player's walk cycle in motion. After that, it's time to make him jump.
Making the Player Jump
For our player to be able to jump, we need to implement four things:
- Upward motion triggered by the up arrow key.
- Gravity, to bring the player back down after reaching the top of his jump.
- Collision detection, so the player doesn't fall through the ground.
- A jumping animation.
Triggering the jump
To simply make the player move up, we can just add another elif
, like so:
def update(self):
# ...
if key[pygame.K_LEFT]:
self.facing_left = True
self.walk_animation()
self.move(-self.speed,0)
elif key[pygame.K_RIGHT]:
self.facing_left = False
self.walk_animation()
self.move(self.speed,0)
elif key[pygame.K_UP]:
self.move(0,-self.speed)
else:
self.image = self.stand_image
If you try the game now, you should notice a couple of problems with this approach. Besides the lack of gravity, we can only jump straight up, and must release the left and right arrow keys before we may do so. Much of the gameplay in platformers is reliant on the player's ability to jump to the left or right, so this won't do. To fix this, we'll change our last elif
to a separate if
statement:
if key[pygame.K_LEFT]:
self.facing_left = True
self.walk_animation()
self.move(-self.speed,0)
elif key[pygame.K_RIGHT]:
self.facing_left = False
self.walk_animation()
self.move(self.speed,0)
else:
self.image = self.stand_image
if key[pygame.K_UP]:
self.move(0,-self.speed)
We also probably want to be able to jump at a different speed to our walking pace, so let's define another variable and use it.
def __init__(self, startx, starty):
# ...
self.speed = 4
self.jumpspeed = 20
def update(self):
# ...
if key[pygame.K_UP]:
self.move(0,-self.jumpspeed)
That's better, but now we really need some gravity!
Adding gravity
Up until now, we've had only a single operation manipulating our horizontal or vertical speed per update loop. With the addition of gravity, this will change, so we need to restructure our code to calculate our net horizontal and vertical movement before calling move
. Let's change the update
function like so:
def update(self):
hsp = 0 # horizontal speed
vsp = 0 # vertical speed
# check keys
key = pygame.key.get_pressed()
if key[pygame.K_LEFT]:
self.facing_left = True
self.walk_animation()
hsp = -self.speed
elif key[pygame.K_RIGHT]:
self.facing_left = False
self.walk_animation()
hsp = self.speed
else:
self.image = self.stand_image
if key[pygame.K_UP]:
vsp = -self.jumpspeed
# movement
self.move(hsp,vsp)
We've added two variables, hsp
and vsp
, to represent our horizontal speed and vertical speed. Instead of calling move
when each key is pressed, we work with these variables throughout the update
method and then pass their final values into a single move
call at the end.
But wait! It makes sense for horizontal speed to be set to 0 at the start of every update loop, because it is directly controlled by the player's key presses. When the left arrow is held down, the player moves left at a speed of 4 pixels per loop; when the left arrow is released, the player instantly stops. Vertical speed will be less controllable β while pressing the up arrow will initiate a jump, releasing it should not stop the player in mid-air. Therefore, vertical speed must persist between loops.
We can accomplish this by moving the vsp
definition into __init__
and making it an instance variable.
def __init__(self, startx, starty)
# ...
self.vsp = 0 # vertical speed
def update(self):
hsp = 0 # horizontal speed
# ...
if key[pygame.K_UP]:
self.vsp = -self.jumpspeed
# movement
self.move(hsp,self.vsp)
Now we can implement gravity. We'll do this by adding a small constant to the player's vertical speed (vsp
) until it reaches terminal velocity.
def __init__(self, startx, starty)
# ...
self.gravity = 1
def update(self):
# ...
if key[pygame.K_UP]:
self.vsp = -self.jumpspeed
# gravity
if self.vsp < 10: # 9.8 rounded up
self.vsp += self.gravity
# movement
self.move(hsp,self.vsp)
Start up the game now, and the player will fall straight down, through the ground and off the screen. Gravity's working, but we need somewhere for the player to land.
Adding collision detection
Collision detection is a key element of most graphical games. In PyGame, the bulk of collision detection involves checking whether rectangles intersect with each other. Luckily, PyGame provides a number of useful built-ins for doing this, so we won't have to think too much about the internal workings of collisions.
Let's add some collision detection now, near the top of our update
method. We'll create a variable called onground
and set it to the result of pygame.sprite.spritecollideany()
.
def update(self):
hsp = 0 # horizontal speed
onground = pygame.sprite.spritecollideany(self, boxes)
This PyGame method takes two arguments: a single sprite and a group of sprites. It returns whether the sprite given as the first argument, i.e. the player, has a collision with any of the sprites in the group given as the second argument, i.e. the boxes. So we'll know that the player is on a box when it returns True
.
We can pass the boxes
group into the player's update
method by making a couple of code changes:
def update(self, boxes):
hsp = 0 # horizontal speed
onground = pygame.sprite.spritecollideany(self, boxes)
# ...
def main():
# ...
while True:
pygame.event.pump()
player.update(boxes)
Now that we can tell whether the player is on the ground, we can prevent jumping in mid-air by adding a condition to our jump code:
def update(self, boxes):
# ...
if key[pygame.K_UP] and onground:
self.vsp = -self.jumpspeed
To stop the player from falling through the ground, we'll add the following code to our gravity implementation:
def update(self, boxes):
# ...
# gravity
if self.vsp < 10 and not onground: # 9.8: rounded up
self.vsp += self.gravity
# stop falling when the ground is reached
if self.vsp > 0 and onground:
self.vsp = 0
Adding a jumping animation
Lastly, we'll use our last alien image (p1_jump.png
) to give our player a jumping animation. First create self.jump_image
in __init__
:
def __init__(self, startx, starty):
super().__init__("p1_front.png", startx, starty)
self.stand_image = self.image
self.jump_image = pygame.image.load("p1_jump.png")
# ...
Then create the following Player
method:
def jump_animation(self):
self.image = self.jump_image
if self.facing_left:
self.image = pygame.transform.flip(self.image, True, False)
Our jump animation only has one frame, so the code is much simpler than what we used for our walking animation. To trigger this method when the player is in the air, alter the gravity implementation like so:
def update(self, boxes):
# ...
# gravity
if self.vsp < 10 and not onground: # 9.8 rounded up
self.jump_animation()
self.vsp += self.gravity
Run the game, and you should be able to run and jump! Be careful not to fall off the edge.
Refining the Game
At this point, we have our game working on a basic level, but it could use some refinements. First, the jumping is quite unresponsive to user input: pressing the up arrow for any length of time results in the same size jump. Second, our collision detection will only prevent the player from falling through the floor, not walking through walls or jumping through the ceiling.
We're going to iterate on our code to fix both of these shortcomings.
Making jumps variable
It would be nice if the player could control the height of their jump by holding the jump key down for different lengths of time. This is fairly simple to implement β we just need a way to reduce the speed of a jump if the player releases the jump key while the player is still moving up. Add the following code to the player's __init__
method.
class Player(Sprite):
def __init__(self, startx, starty):
# ...
self.min_jumpspeed = 3
self.prev_key = pygame.key.get_pressed()
Here we've added a prev_key
instance variable that will track the state of the keyboard in the previous update loop, and a min_jumpspeed
variable, which will be the smallest jump we'll allow the player to do, by just tapping the jump key.
Now let's add variable jumping to the update
method, between the code that handles the up arrow key and the code that handles gravity:
def update(self, boxes)
# ...
if key[pygame.K_UP] and onground:
self.vsp = -self.jumpspeed
# variable height jumping
if self.prev_key[pygame.K_UP] and not key[pygame.K_UP]:
if self.vsp < -self.min_jumpspeed:
self.vsp = -self.min_jumpspeed
self.prev_key = key
# gravity
if self.vsp < 10: # 9.8 rounded up
self.vsp += self.gravity
The if
statement we've just added will evaluate to True
if the up arrow key was pressed in the previous loop but is not longer pressed, i.e. it has been released. When that happens, we cut off the player's jump by reducing its speed to the min_jumpspeed
. We then set self.prev_key
to the current keyboard state in preparation for the next loop.
Try the game now, and you should notice a different height of jump when lightly tap the up arrow key versus when you hold it down. Play around with the value of min_jumpspeed
and see what difference it makes.
Refining collision detection
As mentioned above, the only collision detection we've implemented applies to the ground beneath the player's feet, so he will be able to walk through walls and jump through ceilings. See this for yourself by adding some boxes above and next to the player in the main
method.
def main():
# ...
boxes = pygame.sprite.group()
for bx in range(0,400,70):
boxes.add(Box(bx,300))
boxes.add(Box(330,230))
boxes.add(Box(100,70))
Another issue that you may have already noticed is that the player sinks into the ground after some jumps β this results from the imprecision of our collision detection.
We're going to fix these problems by making a subtle change to how we deal with collisions with boxes. Rather than deciding that we're on the ground when the player sprite is in collision with a box, we'll check whether the player is 1 pixel above a collision with a box. We'll then apply the same principle for left, right and up, stopping the player just before a collision.
First, let's give the player a check_collision
method to make these checks:
class Player(Sprite):
# ...
def check_collision(self, x, y, boxes):
self.rect.move_ip([x,y])
collide = pygame.sprite.spritecollideany(self, boxes)
self.rect.move_ip([-x,-y])
return collide
Here, we're moving the player by a specified amount, checking for a collision, and then moving the player back. This back and forth movement happens before the player is drawn to the screen, so the user won't notice anything.
Let's change our onground
check to use this method:
def update(self, boxes):
hsp = 0
onground = self.check_collision(0, 1, boxes)
Run the game now, and you may be able to notice a very slight difference in how the player stands on the ground from before.
This doesn't yet solve our horizontal and upward collisions problems, though. For that, we'll need to implement our new check_collision
method directly into the player's move
method. The first thing we'll need to do is prepare the x
and y
parameters for additional processing:
def move(self, x, y):
dx = x
dy = y
self.rect.move_ip([dx,dy])
Then we're going to check for collisions, so we need to start passing boxes
into move
. We're going to do this for x
and y
separately, starting with y
. We'll check for a collision after moving dy
pixels vertically, decrementing dy
until we no longer collide with a box:
def update(self, boxes):
# ...
# movement
self.move(hsp, self.vsp, boxes)
def move(self, x, y, boxes):
dx = x
dy = y
while self.check_collision(0, dy, boxes):
dy -= 1
self.rect.move_ip([dx,dy])
But wait! This code will only work as intended if we're moving down and dy
is positive. If dy
is negative, this will just move us further into a collision, not away from it. To fix this, we'll need to import numpy
at the top of our file, so we can use numpy.sign
.
import pygame, numpy
# ...
numpy.sign
takes an integer and returns 1 if it's positive, -1 if it's negative, and 0 if it's 0. This is exactly the functionality we need!
def move(self, x, y, boxes):
dx = x
dy = y
while self.check_collision(0, dy, boxes):
dy -= numpy.sign(dy)
self.rect.move_ip([dx,dy])
Now do the same for dx
. As we've already figured out the appropriate dy
for our movement, we'll include that in the collision check.
def move(self, x, y, boxes):
dx = x
dy = y
while self.check_collision(0, dy, boxes):
dy -= numpy.sign(dy)
while self.check_collision(dx, dy, boxes):
dx -= numpy.sign(dx)
self.rect.move_ip([dx,dy])
Run the game. The player should now stop when he runs into a wall or jumps into a ceiling.
Where Next?
If you'd like to continue working on this game, you can find a large number of matching art assets here. Try implementing some of these features:
- More jump refinements, such as jump grace time and input buffering.
- Moving platforms.
- Slopes.
- Water and swimming mechanics.
- Hazards like spike pits and enemies who are also subject to gravity.
- Double jumping.
If you followed along you'll have your own repl to expand, if not you can fork ours here.
Top comments (0)