Chapter 13 - Collision Detection and Input with Pygame

A very common behavior in most graphical games is collision detection. Collision detection is figuring when two things on the screen have touched (that is, collided with) each other. This is used very often in computer games. For example, if the player touches an enemy they may lose health or a game life. Or we may want to know when the player has touched a coin so that they automatically pick it up. Our next example program will cover this basic technique.

Type the following into a new file and save it as 3_CollisionDetection.py.

3_CollisionDetection.py
  1. import pygame, sys, time, random
  2. from pygame.locals import *
  3. def doRectsOverlap(rect1, rect2):
  4.     for a, b in [(rect1, rect2), (rect2, rect1)]:
  5.         # Check if a's corners are inside b
  6.         if ((isPointInsideRect(a.left, a.top, b)) or
  7.             (isPointInsideRect(a.left, a.bottom, b)) or
  8.             (isPointInsideRect(a.right, a.top, b)) or
  9.             (isPointInsideRect(a.right, a.bottom, b))):
  10.             return True
  11.     return False
  12. def isPointInsideRect(x, y, rect):
  13.     if (x > rect.left) and (x < rect.right) and (y > rect.top) and (y < rect.bottom):
  14.         return True
  15.     else:
  16.         return False
  17. # set up pygame
  18. pygame.init()
  19. # set up the window
  20. WINDOWWIDTH = 400
  21. WINDOWHEIGHT = 400
  22. windowSurface = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT), 0, 32)
  23. pygame.display.set_caption('Collision Detection')
  24. # set up direction variables
  25. DOWNLEFT = 1
  26. DOWNRIGHT = 3
  27. UPLEFT = 7
  28. UPRIGHT = 9
  29. MOVESPEED = 4
  30. # set up the colors
  31. BLACK = (0, 0, 0)
  32. GREEN = (0, 255, 0)
  33. WHITE = (255, 255, 255)
  34. # set up the bouncer and food data structures
  35. foodCounter = 0
  36. NEWFOOD = 40
  37. FOODSIZE = 20
  38. bouncer = {'rect':pygame.Rect(300, 100, 50, 50), 'dir':UPLEFT}
  39. foods = []
  40. for i in range(20):
  41.     foods.append(pygame.Rect(random.randint(0, WINDOWWIDTH - FOODSIZE), random.randint(0, WINDOWHEIGHT - FOODSIZE), FOODSIZE, FOODSIZE))
  42. # run the game loop
  43. while True:
  44.     # check for the QUIT event
  45.     for event in pygame.event.get():
  46.         if event.type == QUIT:
  47.             pygame.quit()
  48.             sys.exit()
  49.     foodCounter += 1
  50.     if foodCounter >= NEWFOOD:
  51.         # add new food
  52.         foodCounter = 0
  53.         foods.append(pygame.Rect(random.randint(0, WINDOWWIDTH - FOODSIZE), random.randint(0, WINDOWHEIGHT - FOODSIZE), FOODSIZE, FOODSIZE))
  54.     # draw the black background onto the surface
  55.     windowSurface.fill(BLACK)
  56.     
  57.     # move the bouncer data structure
  58.     if bouncer['dir'] == DOWNLEFT:
  59.         bouncer['rect'].left -= MOVESPEED
  60.         bouncer['rect'].top += MOVESPEED
  61.     if bouncer['dir'] == DOWNRIGHT:
  62.         bouncer['rect'].left += MOVESPEED
  63.         bouncer['rect'].top += MOVESPEED
  64.     if bouncer['dir'] == UPLEFT:
  65.         bouncer['rect'].left -= MOVESPEED
  66.         bouncer['rect'].top -= MOVESPEED
  67.     if bouncer['dir'] == UPRIGHT:
  68.         bouncer['rect'].left += MOVESPEED
  69.         bouncer['rect'].top -= MOVESPEED
  70.     # check if the bouncer has move out of the window
  71.     if bouncer['rect'].top < 0:
  72.         # bouncer has moved past the top
  73.         if bouncer['dir'] == UPLEFT:
  74.             bouncer['dir'] = DOWNLEFT
  75.         if bouncer['dir'] == UPRIGHT:
  76.             bouncer['dir'] = DOWNRIGHT
  77.     if bouncer['rect'].bottom > WINDOWHEIGHT:
  78.         # bouncer has moved past the bottom
  79.         if bouncer['dir'] == DOWNLEFT:
  80.             bouncer['dir'] = UPLEFT
  81.         if bouncer['dir'] == DOWNRIGHT:
  82.             bouncer['dir'] = UPRIGHT
  83.     if bouncer['rect'].left < 0:
  84.         # bouncer has moved past the left side
  85.         if bouncer['dir'] == DOWNLEFT:
  86.             bouncer['dir'] = DOWNRIGHT
  87.         if bouncer['dir'] == UPLEFT:
  88.             bouncer['dir'] = UPRIGHT
  89.     if bouncer['rect'].right > WINDOWWIDTH:
  90.         # bouncer has moved past the right side
  91.         if bouncer['dir'] == DOWNRIGHT:
  92.             bouncer['dir'] = DOWNLEFT
  93.         if bouncer['dir'] == UPRIGHT:
  94.             bouncer['dir'] = UPLEFT
  95.     # draw the bouncer onto the surface
  96.     pygame.draw.rect(windowSurface, WHITE, bouncer['rect'])
  97.     
  98.     # check if the bouncer has intersected with any food rectangles.
  99.     for food in foods[:]:
  100.         if doRectsOverlap(bouncer['rect'], food):
  101.             foods.remove(food)
  102.     # draw the food
  103.     for i in range(len(foods)):
  104.         pygame.draw.rect(windowSurface, GREEN, foods[i])
  105.     # draw the window onto the screen
  106.     pygame.display.update()
  107.     time.sleep(0.02)

Code Explanation

In this demo, we have one white square (which we will call the bouner) bouncing off the edges of the walls just like in the animation program in the last chapter. However, we also have green squares (which we will call food) randomly appearing on the screen. Whenever the bouncer touches any of the food, we have the food disappear from the screen.

Much of this code is similar to the animation program, so we will skip over explaining how to make the bouncer move and bounce off of the walls.

  1. import pygame, sys, time, random
  2. from pygame.locals import *

The collision detection program also makes use of the random module, so we import it along with everything else.

  1. def doRectsOverlap(rect1, rect2):

In order to do collision detection, we need to determine if two rectangles intersect each other or not. Here is a picture of two intersecting rectangles (on the left) and rectangles that do not intersect (on the right):

We will make a single function that is passed two pygame.Rect objects. The function, doRectsOverlap(), will return True if they do and False if they don't.

There is a very simple rule we can follow to determine if rectangles intersect (that is, collide). Look at each of the four corners on both rectangles. If at least one of these eight corners is inside the other rectangle, then we know that the two rectangles have collided. We will use this fact to determine if doRectsOverlap() returns True or False

  1.     for a, b in [(rect1, rect2), (rect2, rect1)]:
  2.         # Check if a's corners are inside b
  3.         if ((isPointInsideRect(a.left, a.top, b)) or
  4.             (isPointInsideRect(a.left, a.bottom, b)) or
  5.             (isPointInsideRect(a.right, a.top, b)) or
  6.             (isPointInsideRect(a.right, a.bottom, b))):
  7.             return True

Above is the code that checks if one rectangle's corners are inside another. Later we will create a function called isPointInsideRect() that returns True if the x- and y-coordinates of the point are inside the rectangle. We call this function four times for each of the four corners, and if any of these calls return True, the or operators will make the entire condition True.

The parameters for doRectsOverlap() are rect1 and rect2. We first want to check if rect1's corners are inside rect2 and then check if rect2's corners are in rect1.

We don't want to repeat the code that checks all four corners for both rect1 and rect2, so instead we use a and b on lines 7, 8, 9, and 10. The for loop on line 5 uses the multiple assignment trick so that on the first iteration, a is set to rect1 and b is set to rect2. On the second iteration through the loop, it is the opposite. a is set to rect2 and b is set to rect1.

We do this because then we only have to type the code for the if statement on line 7 once. This is good, because this is a very long if statement. The less code we have to type for our program, the better.

  1.     return False

If we never return True from the previous if statements, then none of the eight corners we checked are in the other rectangle. In that case, the rectangles do not collide and we return False.

  1. def isPointInsideRect(x, y, rect):
  2.     if (x > rect.left) and (x < rect.right) and (y > rect.top) and (y < rect.bottom):
  3.         return True

The isPointInsideRect function is used by the doRectsOverlap() function. isPointInsideRect() will return True if the x- and y-coordinates passed to it as the first and second parameters are located "inside" the pygame.Rect object that is passed as the third parameter. Otherwise, this function returns False.

Here is an example picture of a rectangle and several dots. The dots and the corners of the rectangle are labeled with coordinates:

The pattern that you need to notice is that the points that are inside the rectangles have an x-coordinate that is greater than the x-coordinate of the left side and less than the x-coordinate of the right side, and have a y-coordinate that is greater than the y-coordinate of the top side and less than the y-coordinate of the bottom side. We combine all four of these conditions into the if statement's condition with and operators because all four of the conditions must be True.

  1.     else:
  2.         return False

If just one of the four expressions in the condition on line 16 is False, then we should have isPointInsideRect return the value False.

This function will be called from the doRectsOverlap() function to see if any of the corners in the two pygame.Rect objects are inside each other. These two functions give us the power to do collision detection between two rectangles.

  1. pygame.display.set_caption('Collision Detection')

Lines 22 to 42 are things we've already covered in the animation Pygame program in the last chapter, so we won't cover it again.

  1. # set up the bouncer and food data structures
  2. foodCounter = 0
  3. NEWFOOD = 40
  4. FOODSIZE = 20

We are going to set up a few variables for the food blocks that appear on the screen. foodCounter will start at the value

  1. bouncer = {'rect':pygame.Rect(300, 100, 50, 50), 'dir':UPLEFT}

We are going to set up a new data structure called bouncer. bouncer is a dictionary with two keys. The value stored in the 'rect' key will be a pygame.Rect object that represents the bouncer's size and position. The value stored in the 'dir' key will be a direction that the bouncer is currently moving. The bouncer will move the same way the blocks did in our previous animation program: moving in diagonal directions and bouncing off of the sides of the window.

  1. foods = []
  2. for i in range(20):
  3.     foods.append(pygame.Rect(random.randint(0, WINDOWWIDTH - FOODSIZE), random.randint(0, WINDOWHEIGHT - FOODSIZE), FOODSIZE, FOODSIZE))

Our program will keep track of every food square with a list of pygame.Rect objects called foods. At the start of the program, we want to create twenty food squares randomly placed around the screen. We can use the random.randint() function to come up with random xy coordinates.

On line 51, we will call the pygame.Rect() constructor function to return a new pygame.Rect object that will represent the position and size of the food square. The first two parameters for pygame.Rect() are the xy coordinates of the top left corner. We want the random coordinate to be between 0 and the size of the window minus the size of the food square. If we had the random coordinate between 0 and the size of the window, then the food square might be pushed outside of the window altogether. Look at the diagram below.

The square on the left has an x-coordinate of its top left corner at 380. Because the food square is 20 pixels wide, the right edge of the food square is at 400. (This is because 380 + 20 = 400.) The square on the right has an x-coordinate of its top left corner at 400. Because the food square is 20 pixels wide, the right edge of the food square is at 420, which puts the entire square outside of the window (and not viewable to the user).

Lines 70 to 108 cause the bouncer to move around the window and bounce off of the edges of the window. This code is very similar to lines 45 to 83 of our animation program in the last chapter, so we will not go over them again here.

  1.     # draw the bouncer onto the surface
  2.     pygame.draw.rect(windowSurface, WHITE, bouncer['rect'])

After moving the bouncer, we now want to draw it on the window in its new position. We call the pygame.draw.rect() function to draw a rectangle. The windowSurface passed for the first parameter tells the computer which pygame.Surface object to draw the rectangle on. The WHITE variable, which has (255, 255, 255) stored in it, will tell the computer to draw a white rectangle. The pygame.Rect object stored in the bouncer dictionary at the 'rect' key tells the position and size of the rectangle to draw. This is all the information needed to draw a white rectangle on windowSurface.

Remember, we are not done drawing things on the windowSurface object yet. We still need to draw a green square for each food square in the foods list. And we are just "drawing" rectangles on the windowSurface object. This pygame.Surface object is only inside the computer's memory, which is much faster to modify than the screen. The window on the screen will not be updated until we call the pygame.display.update() function.

  1.     # check if the bouncer has intersected with any food rectangles.
  2.     for food in foods[:]:

Before we draw the food squares, we want to see if the bouncer has overlapped any of the food squares. If it has, we will remove that food square from the foods list. This way, the computer won't draw any food squares that the bouncer has "eaten".

On each iteration through the for loop, the current food square from the foods (plural) list will be stored inside a variable called food (singular).

Don't Add to or Delete from a List while Iterating Over It

Notice that there is something slightly different with this for loop. If you look carefully at line 114, we are not iterating over foods but actually over foods[:]. Just as foods[:2] would return a copy of the list with the items from the start and up to (but not including) the item at index 2, and just as foods[3:] would return a copy of the list with the items from index 3 to the end of the list, foods[:] will give you a copy of the list with the items from the start to the end. Basically, foods[:] creates a new list with a copy of all the items in foods.

Why would we want to iterate over a copy of the list instead of the list itself? It is because we cannot add or remove items from a list while we are iterating over it. Python can lose track of what the next value of food should be if the size of the foods list is always changing. Think of how difficult it would be for you if you tried to count the number of jelly beans in a jar while someone was adding or removing jelly beans. But if we iterate over a copy of the list (and the copy never changes), then adding or removing items from the original list won't be a problem.

TODO - earlier, describe how objects in a variable are really references. Also, are lists also considered objects themselves?

Code Explanation continued...

  1.         if doRectsOverlap(bouncer['rect'], food):
  2.             foods.remove(food)

Line 115 is where our doRectsOverlap() function that we defined earlier comes in handy. We pass two pygame.Rect objects to doRectsOverlap(): the bouncer and the current food square. If these two rectangles overlap, then doRectsOverlap() will return True and we will remove the overlapping food squares from foods list.

  1.     # draw the food
  2.     for food in foods:
  3.         pygame.draw.rect(windowSurface, GREEN, food)

The code on lines 119 and 120 are very similiar to how we drew the white square for the player. We will loop through each food square in the foods list, and then draw the rectangle onto the windowSurface surface.

These past few programs are interesting to watch, but the user does not get to actually control anything. In this next program, we will learn how to get input from the keyboard. Keyboard input is handled in Pygame by using events.

Start a new file and type in the following code, then save it as 4_Input.py.

4_Input.py
  1. import pygame, sys, time, random
  2. from pygame.locals import *
  3. # set up pygame
  4. pygame.init()
  5. # set up the window
  6. WINDOWWIDTH = 400
  7. WINDOWHEIGHT = 400
  8. windowSurface = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT), 0, 32)
  9. pygame.display.set_caption('Mouse')
  10. # set up the colors
  11. BLACK = (0, 0, 0)
  12. GREEN = (0, 255, 0)
  13. WHITE = (255, 255, 255)
  14. # set up the player and food data structure
  15. foodCounter = 0
  16. NEWFOOD = 40
  17. FOODSIZE = 20
  18. player = pygame.Rect(300, 100, 50, 50)
  19. foods = []
  20. for i in range(20):
  21.     foods.append(pygame.Rect(random.randint(0, WINDOWWIDTH - FOODSIZE), random.randint(0, WINDOWHEIGHT - FOODSIZE), FOODSIZE, FOODSIZE))
  22. # set up keyboard variables
  23. moveLeft = False
  24. moveRight = False
  25. moveUp = False
  26. moveDown = False
  27. MOVESPEED = 6
  28. # run the game loop
  29. while True:
  30.     # check for events
  31.     for event in pygame.event.get():
  32.         if event.type == QUIT:
  33.             pygame.quit()
  34.             sys.exit()
  35.         if event.type == KEYDOWN:
  36.             # change the keyboard variables
  37.             if event.key == K_LEFT or event.key == ord('a'):
  38.                 moveRight = False
  39.                 moveLeft = True
  40.             if event.key == K_RIGHT or event.key == ord('d'):
  41.                 moveLeft = False
  42.                 moveRight = True
  43.             if event.key == K_UP or event.key == ord('w'):
  44.                 moveDown = False
  45.                 moveUp = True
  46.             if event.key == K_DOWN or event.key == ord('s'):
  47.                 moveUp = False
  48.                 moveDown = True
  49.         if event.type == KEYUP:
  50.             if event.key == K_ESCAPE:
  51.                 pygame.quit()
  52.                 sys.exit()
  53.             if event.key == K_LEFT or event.key == ord('a'):
  54.                 moveLeft = False
  55.             if event.key == K_RIGHT or event.key == ord('d'):
  56.                 moveRight = False
  57.             if event.key == K_UP or event.key == ord('w'):
  58.                 moveUp = False
  59.             if event.key == K_DOWN or event.key == ord('s'):
  60.                 moveDown = False
  61.             if event.key == ord('x'):
  62.                 player.top = random.randint(0, WINDOWHEIGHT - player.height)
  63.                 player.left = random.randint(0, WINDOWWIDTH - player.width)
  64.         
  65.         if event.type == MOUSEBUTTONUP:
  66.             foods.append(pygame.Rect(event.pos[0], event.pos[1], FOODSIZE, FOODSIZE))
  67.     foodCounter += 1
  68.     if foodCounter >= NEWFOOD:
  69.         # add new food
  70.         foodCounter = 0
  71.         foods.append(pygame.Rect(random.randint(0, WINDOWWIDTH - FOODSIZE), random.randint(0, WINDOWHEIGHT - FOODSIZE), FOODSIZE, FOODSIZE))
  72.         
  73.     # draw the black background onto the surface
  74.     windowSurface.fill(BLACK)
  75.     # move the player
  76.     if moveDown and player.bottom < WINDOWHEIGHT:
  77.         player.top += MOVESPEED
  78.     if moveUp and player.top > 0:
  79.         player.top -= MOVESPEED
  80.     if moveLeft and player.left > 0:
  81.         player.left -= MOVESPEED
  82.     if moveRight and player.right < WINDOWWIDTH:
  83.         player.right += MOVESPEED
  84.     # draw the player onto the surface
  85.     pygame.draw.rect(windowSurface, WHITE, player)
  86.     
  87.     # check if the player has intersected with any food rectangles.
  88.     for food in foods[:]:
  89.         if player.colliderect(food):
  90.             foods.remove(food)
  91.     # draw the food
  92.     for i in range(len(foods)):
  93.         pygame.draw.rect(windowSurface, GREEN, foods[i])
  94.     # draw the window onto the screen
  95.     pygame.display.update()
  96.     time.sleep(0.02)

Code Explanation

In this program, we can still control the bouncer by using the keyboard. However, we can also click anywhere in the GUI window and create new food objects at the coordinates where we clicked.

  1. pygame.display.set_caption('Mouse')

First, we set the caption of the window's title bar to the string to 'Mouse'.

  1. # set up keyboard variables
  2. moveLeft = False
  3. moveRight = False
  4. moveUp = False
  5. moveDown = False

We are going to use four different boolean variables to keep track of which of the arrow keys are being held down. For example, when the user pushes the left arrow key on her keyboard, we will set the moveLeft variable to True. When she lets go of the key, we will set the moveLeft variable back to False.

The code to handle the key press and key release events is below. But at the start of the program, we will set all of these variables to False.

  1.         if event.type == KEYDOWN:

Pygame has another event type called KEYDOWN. On line 40, we check if the event.type attribute is equal to the QUIT value to check if we should exit the program. But there are other events that Pygame can generate. Here is a brief list of the events that could be returned by pygame.event.get():

  1.             # change the keyboard variables
  2.             if event.key == K_LEFT or event.key == ord('a'):
  3.                 moveRight = False
  4.                 moveLeft = True
  5.             if event.key == K_RIGHT or event.key == ord('d'):
  6.                 moveLeft = False
  7.                 moveRight = True
  8.             if event.key == K_UP or event.key == ord('w'):
  9.                 moveDown = False
  10.                 moveUp = True
  11.             if event.key == K_DOWN or event.key == ord('s'):
  12.                 moveUp = False
  13.                 moveDown = True

If the event type is !KEYDOWN, then the !event object will have a !key attribute that will tell us which key was pressed down. On line 45, we can compare this value to !K_LEFT, which represents the left arrow key on the keyboard. We will do this for each of the arrow keys: !K_LEFT, !K_RIGHT, !K_UP, !K_DOWN.

When one of these keys is pressed down, we will set the corresponding movement variable to !True. We will also set the movement variable of the opposite direction to !False. For example, the program executes lines 46 and 47 when the left arrow key has been pressed. In this case, we will set !moveLeft to !True and !moveRight to !False (even though !moveRight might already be !False, we set it to !False just to be sure).

You may notice that on line 45, in !event.key can either be equal to !K_LEFT or !ord('a'). The value in !event.key is set to the integer ASCII value of the key that was pressed on the keyboard. (There is no ASCII value for the arrow keys, which is why we use the constant variable !K_LEFT.) You can use the !ord() function to get the ASCII value of any single character to compare it with !event.key.

By executing the code on lines 46 and 47 if the keystroke was either !K_LEFT or !ord('a'), we make the left arrow key and the A key do the same thing. You may notice that the W, A, S, and D keys are all used as alternates for changing the movement variables. This is because some people may want to use their left hand to press the WASD keys instead of their right hand to press the arrow keys. Our program offers them both!

  1.         if event.type == KEYUP:

When the user releases the key that they are holding down, a !KEYUP event is generated.

  1.             if event.key == K_ESCAPE:
  2.                 pygame.quit()
  3.                 sys.exit()

If the key that the user released was the Esc key, then we want to terminate the program. Remember, in Pygame you must call the !pygame.quit() function before calling the !sys.exit() function. We want to do this when the user releases the Esc key, not when they first Esc key down.

  1.             if event.key == K_LEFT or event.key == ord('a'):
  2.                 moveLeft = False
  3.             if event.key == K_RIGHT or event.key == ord('d'):
  4.                 moveRight = False
  5.             if event.key == K_UP or event.key == ord('w'):
  6.                 moveUp = False
  7.             if event.key == K_DOWN or event.key == ord('s'):
  8.                 moveDown = False

If the user released one of the keys that moves the player, then we want to set the movement variable that corresponds with the key to !False. This will tell the later parts of our program to no longer move the player's square on the screen.

  1.             if event.key == ord('x'):
  2.                 player.top = random.randint(0, WINDOWHEIGHT - player.height)
  3.                 player.left = random.randint(0, WINDOWWIDTH - player.width)

We will also add teleportation to our game. If the user presses the X key, then we will set the position of the user's square to a random place on the window. This will give the user the ability to teleport around the window by pushing the X key (though they can't control where they will teleport: it's completely random).

  1.         if event.type == MOUSEBUTTONUP:
  2.             foods.append(pygame.Rect(event.pos[0], event.pos[1], FOODSIZE, FOODSIZE))

Mouse input is handled by events just like keyboard input is. The !MOUSEBUTTONUP event occurs when the user clicks a mouse button somewhere in our window, and releases the mouse button. The !pos attribute in the !Event object is set to a tuple of two integers for the xy coordinates. On line 74, the x coordinate is stored in !event.pos[0] and the y coordinate is stored in !event.pos[1]. We will create a new !Rect object to represent a new food and place it where the !MOUSEBUTTONUP event occurred. By adding a new !Rect object to the !foods list, a new food square will be displayed on the screen.

  1.     # move the player
  2.     if moveDown and player.bottom < WINDOWHEIGHT:
  3.         player.top += MOVESPEED
  4.     if moveUp and player.top > 0:
  5.         player.top -= MOVESPEED
  6.     if moveLeft and player.left > 0:
  7.         player.left -= MOVESPEED
  8.     if moveRight and player.right < WINDOWWIDTH:
  9.         player.right += MOVESPEED

We have set the movement variables (!moveDown, !moveUp, !moveLeft, and !moveRight) to !True or !False depending on what keys the user has pressed. Now we will actually move the player's square (which is represented by the around by adjusting the xy coordinates of. If !moveDown is set to !True (and the bottom of the player's square is not below the bottom edge of the window), then we move the player's square down by adding !MOVESPEED to the player's current !top attribute.

The rest of the code is similar to the code in the Collision Detection program: draw the food squares and the player squares to the !windowSurface surface, occasionally add a new food square at a random location to the !foods list, check if the player square has collided with any of the food squares, and call !time.sleep(0.02) to slow down the program a little.

TODO - talk about how eerything is really just a representation based on what we store in our data. There is no actual player's square.

TODO - try messing around with the values of the constant variables in our program.

TODO - have I introduced the colliderect() function?