Python & Pygame Tutorial - A lander game, step by step

This is a step by step look at the development of a simple 2D game using Python and Pygame. Some advanced language features such as objects have been deliberately left out, in an effort to make things easier to understand for beginners. I showed these code examples at my Pygame talk at Southend Raspberry Jam 3 on 16th August '14.

It runs in Python 2 or 3, and on Windows, Linux or Raspberry Pi and probably Mac too. You will also need Pygame installed. On a Raspberry Pi (Raspian, the usual distribution) or desktop Linux you can install everything needed by running this in a terminal:

sudo apt-get install idle python-pygame

I recommend IDLE for working with Python files, on all operating systems.

You can download a zip file of this project with all needed files at the bottom of the page, or click here: http://soslug.org/sites/soslug.org/files/andylander_soslug.zip. Get it now and run and look at the examples as you read through, so you can get a feel of what's going on.

So let's start with a program that draws a black background, our lander ship sprite and allows the user to quit: (Open lander0.01.py in IDLE)

Import the Pygame module so we can use it and a couple of others:


import pygame, sys, time

This line imports a bunch of values such as K_ESCAPE for the keycode for the Esc key, so we can type K_ESCAPE rather than pygame.locals.K_ESCAPE each time:


from pygame.locals import *

Start Pygame:


pygame.init()

Create a window that we can use and set the window title:


windowImg = pygame.display.set_mode((800,600))
pygame.display.set_caption("AndyLander")

Load the image we want to use from the file "lander.png" in the local folder "art":


landerImg = pygame.image.load("art/lander.png")


What this actually does is create a Pygame Surface object, and put a reference to it in the variable landerImg. In Pygame(and SDL) all sprites and drawable images are called "Surfaces". windowImg is also a pygame Surface object.

Make some variables for the lander ship's current position, i.e. coordinates. X is the distance from the left side of the screen, Y is the distance from the top edge:


landerX = 200
landerY = 200

Repeat forever and make the program quit when the user clicks the "X" in the top right hand corner:


while True:
    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            sys.exit()


pygame.event.get() returns a list of all events that have happened since the last update, for example a mouse move, click or key press event. for event in pygame.event.get() will process each event one at a time in a loop; in this case we are using an if statement to check to see if a QUIT event has occurred, and react accordingly.

Finally, fill the background with black and draw our lander at the correct position: (notice the indent to signify this code is still inside the while loop)


    windowImg.fill(pygame.Color(0,0,0))
    windowImg.blit(landerImg, (landerX, landerY) )
    
    pygame.display.update()
    time.sleep(0.1)

The on the end simply tells python to wait 0.1 seconds, so the game doesn't play too fast (the game doesn't do anything yet).

--------------------------------------------------------------------------------------------------------------------------------

Now let's add a way to move the lander ship: (lander0.02.py)

Notice that any text after a hash(#) character is a different colour when opened in a decent code editor such as IDLE, Gedit or Kate. These are comments; python ignores all text on a line after a hash character.

When reading the files, all new lines/sections are marked with seven hashes: #######

Here is the new section:

while True:
    #input (check keys)
    ...
    if pygame.key.get_pressed()[K_a]: landerX -= 5 #######
    if pygame.key.get_pressed()[K_d]: landerX += 5 #######

This is actually a little complicated. pygame.key.get_pressed() returns a list of boolean (True or False) values, and each one corresponds to a specific key on the keyboard. The [K_a] on the end extracts the specific value we want: whether the A key is currently being pressed.

It could also be done like this:

while True:
    #input (check keys)
    ...
    keyStates = pygame.key.get_pressed()
    if keyStates[K_a]: landerX -= 5
    if keyStates[K_d]: landerX += 5

The landerX += 5 simply increases the value stored inside the variable landerX by 5. landerX stores the current horizontal position (the distance from the left side of the screen) so increasing it moves it to the right; decreasing it moves it to the left. Since it is immediately after the colon(:) of the if statement, it will only be run if the if condition is True(i.e. if the key was pressed). You could put it on the next line and indent it if you wanted, it would be identical:

    if pygame.key.get_pressed()[K_a]:
        landerX -= 5
    if pygame.key.get_pressed()[K_d]:
        landerX += 5

This simple movement also shows why a loop is needed: The screen must be constantly re-drawn to reflect split-second changes in the game environment. The drawing code draws the ship in the most up to date location, by reading the values of the landerX and landerY variables.

--------------------------------------------------------------------------------------------------------------------------------

Time for some momentum: (lander0.03.py)

New bits of code:

velX = 0 #######
velY = 0 #######
while True:
    #input (check keys)
    ...
    if pygame.key.get_pressed()[K_a]: velX -= 1 #######
    if pygame.key.get_pressed()[K_d]: velX += 1 #######
    if pygame.key.get_pressed()[K_w]: velY -= 1 #######
    if pygame.key.get_pressed()[K_s]: velY += 1 #######

Not too much to digest here, once you understand the principles. We have two new variables, velX and velY. These store the current velocity(speed) of the lander on the X and Y axes. They start at 0 (not moving) and pressing the WASD keys modifies the X and Y velocities in the same way pressing A and D modified landerX previously.

Finally, every time the loop is executed, we change landerX and landerY (the lander ship's coordinates) by its velocity rather than a fixed number:

    #process
    landerX += velX #######
    landerY += velY #######

If velocities and coordinates are hard to understand, think of a number line. The lander (player ship) starts on the line at number 0. If velX is 2, then the lander is increased by 2 each time the game updates - it starts at 0, then 2, then 4, then 6, then 8, then 10 and so on. If however velX is 5, then the lander will move across the number line more quickly: starting at 0, then 5, then 10, then 15... this creates almost an illusion of speed, and it happens several times per second. To move in the opposite direction, velX can be -2. Now it will move like this: 6, 4, 2, 0, -2, -4, -6 and so on.

--------------------------------------------------------------------------------------------------------------------------------

One additional line of code and we have gravity! (lander0.04.py)

Here is the new line:

while True:
    ...
    #process
    velY += 0.5 #######
    landerX += velX
    landerY += velY

What's going on? Every time the game updates (i.e. runs through the while loop once) velY, the vertical speed or velocity, is being increased. The Y coordinate is the distance from the top of the screen remember, not from the bottom of the axis like coordinates at school. This has the effect that, over time, the lander feels "pulled" downwards as the Y coordinate increases faster and faster. By pressing W you decrease velY by 1, which is more than 0.5. velY will be decreased by 1 then increased by 0.5, so effectively decreased by 0.5; thus you can fight gravity.

--------------------------------------------------------------------------------------------------------------------------------

Now we can add some obstacles. Well, they will be functionless decoration first. (lander0.05.py)

The very first thing we do is add a new line to load the image we want to use, to represent each rock, and store it as a new variable called "rockImg":

landerImg = pygame.image.load("art/lander.png")
rockImg = pygame.image.load("art/rock.png") #######

Then we make a new variable called rockPositions, which is set to an empty list i.e. []. A list holds a sequence of values.

rockPositions = []

If you wanted a list of the numbers from 1 to 5, you could do

myList = [1, 2, 3, 4, 5]

Now we loop (repeat) some code exactly 5 times:

for i in range(0,5):

Inside this loop we set some variables rx and ry to random numbers in the range 100 to 700 and 100 to 500 respectively, these will be the X and Y coordinates (positions) of our rocks:

    rx = random.randint(100,700)
    ry = random.randint(100,500)

Finally we add a "tuple" (sequence of values) made with these values to our rockPositions list:

    rockPositions.append( (rx,ry) )

Further down we have updated the draw section to draw our 4 rocks:

while True:
    ...
    #output (draw to screen)
    windowImg.fill(pygame.Color(0,0,0))
    windowImg.blit(landerImg, (landerX, landerY) )
    
    #######
    for rockPos in rockPositions:
        windowImg.blit(rockImg, rockPos)

for rockPos in rockPositions starts a loop that will loop through all the values inside rockPositions, putting the value into "rockPos" each time around. windowImg.blit(rockImg, rockPos), inside the loop, draws a rock at the desired position. Because rockPositions has 5 elements inside of it, the loop executes 5 times, drawing 5 rocks at the positions indicated by the contents of the list.

--------------------------------------------------------------------------------------------------------------------------------

Now that we have rocks, let's make them dangerous... (lander0.06.py)

For this to work - checking collisions with the rocks - we need to know the pixel size of both the rock and the lander ship. Pygame has a handy function to achieve this:

landerImg = pygame.image.load("art/lander.png")
rockImg = pygame.image.load("art/rock.png")
landerSize = landerImg.get_size() #######
rockSize = rockImg.get_size() #######

get_size() is a method(function) of pygame's surface objects, that we can use. It returns a tuple with the size in the form: (width, height). Width applies to the X axis and coordinates, height applies to the Y axis and coordinates.

Next we add a new variable "alive" - which starts out as True but may change to False. Best to group peices of code in a sensible way, so all lander ship variables are listed here:

landerX = 50
landerY = 300
velX = 0
velY = 0
alive = True #######

Now, inside the game loop, we check for collisions with rocks, and if we collide, alive is set to False.

while True:
    ...
    #######
    for rockPos in rockPositions:
        if landerX < rockPos[0]+rockSize[0] and landerX+landerSize[0] > rockPos[0]:
            if landerY < rockPos[1]+rockSize[1] and landerY+landerSize[1] > rockPos[1]:
                alive = False

Firstly we loop through all rocks: for rockPos in rockPositions:, just like when we were drawing all the rocks onto the screen previously. Then we examine the coordinates, treating the lander ship and this particular rock as rectangles. The code checks the coordinates to see if the rectangles overlap: for example the left edge of the lander ship(landerX) must be to the left of (less than, i.e. < ) the right edge of the rock (rockPos[0]+rockSize[0]).
The [0] accesses the first number in the tuple, since lists(and arrays) of things in computer programs are usually counted from 0 rather than 1. pos[0] is the X coordinate, and size[0] is the width.

Finally, when the lander ship is no longer alive, we do not draw it:

    #output (draw to screen)
    windowImg.fill(pygame.Color(0,0,0))
    if alive: windowImg.blit(landerImg, (landerX, landerY) ) #######
    for rockPos in rockPositions:
        windowImg.blit(rockImg, rockPos)

When alive is True, the game will draw the landerImg surface (sprite) onto the screen. When the player hits a rock, alive will now be False. Since drawing the lander ship is inside this if statement block, it will no longer be run and the lander ship will not be shown on the screen any more.

--------------------------------------------------------------------------------------------------------------------------------

Rather than flying around forever, we need something to land on. (lander0.07.py)

At the top of the program another image is now being loaded, for the goal platform, as well as get_size(). So we have two new variables goalImg and goalSize:

landerImg = pygame.image.load("art/lander.png")
rockImg = pygame.image.load("art/rock.png")
goalImg = pygame.image.load("art/goalPad.png") #######
landerSize = landerImg.get_size()
rockSize = rockImg.get_size()
goalSize = goalImg.get_size() #######

We also need new variables to store information about the goal: where it is, and if the player has landed on it yet:

goalX = 730 #######
goalY = 500 #######
onGoal = False #######

In the game loop we now have a routine that checks if we have just landed. Just like the rock, some coordinate maths to check if the lander ship is now in the correct location to land, overlapping with the goal in the correct place:

while True:
    ...
    if landerX < goalX+goalSize[0] and landerX+landerSize[0] > goalX: #######
        if landerY+landerSize[1] > goalY and landerY+landerSize[1] < goalY+8:

We also have this line, which checks if the player is moving too fast. abs(number) returns the number and removes any negative sign in front of it. For example, abs(2) is 2 but abs(-2) is also 2. abs(-10) is 10 and so on. We need this, because the player might be moving in a negative direction, too fast. So, we have an if statement that checks the speed is low enough to land safely:

            if abs(velX) < 3 and abs(velY) < 3:

At last, if we have passed all of that, set the player speed to 0 in all directions and set onGoal to True. You win!

                onGoal = True
                velX = 0
                velY = 0

Due to its indentation this else statement applies to if abs(velX) < 3 and abs(velY) < 3: . In the event the player was moving too fast, they would crash. Thus, alive is set to False, killing the lander ship if it was moving too fast when hitting the goal:

            else: alive = False

Gravity has also been changed slightly. Apply gravity only if we are not on the goal. Otherwise, the lander ship would fall through the goal.

    if not onGoal: velY += 0.5

Finally, actually draw the goal too so the player can see it on the screen:

    #output (draw to screen)
    windowImg.fill(pygame.Color(0,0,0))
    windowImg.blit(goalImg, (goalX,goalY) ) #######

--------------------------------------------------------------------------------------------------------------------------------

To feel more like a lander, it should have visible thrust from its rockets... (lander0.08.py)

Firstly we have some new image files to load, again, 4 this time (one per side):

#######
flameLeftImg = pygame.image.load("art/flameLeft.png")
flameRightImg = pygame.image.load("art/flameRight.png")
flameTopImg = pygame.image.load("art/flameTop.png")
flameBottomImg = pygame.image.load("art/flameBottom.png")

To draw the rocket thrust at the correct time, we need to remember if each key has been pressed or not. Enter some new boolean (True or False) variables:

    while True:
    ...
    #######
    moveLeft = False
    moveRight = False
    moveUp = False
    moveDown = False
    if pygame.key.get_pressed()[K_a]: moveLeft = True
    if pygame.key.get_pressed()[K_d]: moveRight = True
    if pygame.key.get_pressed()[K_w]: moveUp = True
    if pygame.key.get_pressed()[K_s]: moveDown = True

Now, for the lander to accelerate (thrust) when the player presses the arrows, we access the new variables:

    #process
    #######
    if moveLeft: velX -= 1
    if moveRight: velX += 1
    if moveUp: velY -= 1
    if moveDown: velY += 1

Finally when drawing the game onto the screen we access the variables again. If moveLeft, then draw the flame on the right hand side. The strange numbers in brackets at the end of the line are needed to place the rocket thrust into the correct place on the edge of the lander each time, rather than always in the top-left corner.

    #output (draw to screen)
    windowImg.fill(pygame.Color(0,0,0))
    windowImg.blit(goalImg, (goalX,goalY) )
    if alive:
        windowImg.blit(landerImg, (landerX, landerY) )
        #######
        if moveLeft: windowImg.blit(flameRightImg, (landerX+landerSize[0], landerY+17-4))
        if moveRight: windowImg.blit(flameLeftImg, (landerX-6, landerY+17-4))
        if moveUp: windowImg.blit(flameBottomImg, (landerX+17-4,landerY+landerSize[1]) )
        if moveDown: windowImg.blit(flameTopImg, (landerX+17-4,landerY-6) )

--------------------------------------------------------------------------------------------------------------------------------

Lastly, what would a lander game be without limited fuel?(lander0.09.py or lander0.09_nogpio.py)

If you are on a Raspberry Pi, connect a red LED with 330Ω resistor to pin 12, and a green LED with 330Ω resistor to pin 11. These are used as a GPIO example as fuel indicators. Only do this if you know what you are doing. You can damage your Raspberry Pi if you make a short circuit by accident!

If you are not on a Raspberry Pi, open lander0.09_nogpio.py instead.

Firstly we import the RPi.GPIO Python module to allow us to interface with the GPIO pins, and then some set up stuff for the Pi's GPIO:

#######
import RPi.GPIO as GPIO

#######
GPIO.setmode(GPIO.BOARD)
greenPin = 11
redPin = 12
GPIO.setup(greenPin, GPIO.OUT)
GPIO.setup(redPin, GPIO.OUT)

Also a fuel variable, starting at 100:

fuel = 100

Here we set the green LED to ON and red LED to OFF, and create some variables to keep track of what state the two LEDs are currently in:

GPIO.output(greenPin, True)
GPIO.output(redPin, False)
greenOn = True
redOn = False

Also when we quit, we want to tell the Python GPIO module to exit cleanly:

while True:
    ...
    for event in pygame.event.get():
        if event.type == QUIT:
            pygame.quit()
            GPIO.cleanup() #######
            sys.exit()

The section where the lander ship accelerates will now only run if we have some fuel left, due to this if statement:

    #process
    #######
    if fuel > 0:

And, it now consumes fuel (reduces the fuel variable) when accelerating(thrusting):

        if alive:
            if moveLeft:
                velX -= 1
                fuel -= 0.3
            if moveRight:
                velX += 1
                fuel -= 0.3
            if moveUp:
                velY -= 1
                fuel -= 0.3
            if moveDown:
                velY += 1
                fuel -= 0.3
    else: fuel = 0

Finally this section turns the green LED off ("Full fuel indicator") when we dip below 80 fuel:

    #output (draw to screen)
    #######
    if fuel < 80 and greenOn:
        GPIO.output(greenPin, False)
        greenOn = False

And this section turns the red LED on ("Low fuel indicator") when we dip below 40 fuel:

    #######
    if fuel < 40 and not redOn:
        GPIO.output(redPin, True)
        redOn = True

In both cases, the redOn and greenOn variables are used to keep track of whether the corresponding LED is on or off, because there is no way to "ask" the GPIO module the current state of a particular pin.

The end! If you have any comments or feedback on this tutorial, feel free to leave a comment below or talk to me (Andy C. Knight, SoSLUG Chair) at a regular SoSLUG meeting or one of our events, such as Southend Raspberry Jams!

AttachmentSize
andylander_soslug.zip11.35 KB