Collision Detection with PyGame

Skill

Collision Detection with PyGame

Posted in:

The last tutorial ended with the creation of a Sprite class. The class had the functionality to move itself as well as detect when it was about to go out of the game screen, and respond to that by changing its direction of movement. In this part, I'm going to show you how to implement simple collision detection in PyGame.

Collision Detection

The first question that needs to be answered is; what exactly is collision detection. Well, as you may have already figured out from the name, it is that part of a game that detects when two (or more) objects are about to collide with each other. Technically, that is where the job of the collision detection part of a game ends. But in most simple games, the part that detects collisions is also responsible for what comes next, responding to those collisions.

Collision detection is one of the few pieces that is present in almost all games that have ever been made. I'm sure you've seen almost realistic physics effects in games like FEAR, GTA 4, or even Crayon Deluxe. Heck, even Prince of Persia and Pac-Man had collision detection. The physics effects are a separate topic, and are also usually handled by a different part of the game, appropriately called the Physics Engine. But before the physics engine can go ahead and do its magic, it needs to be activated by something. That something is usually the collision detection part of a game.

Techniques for Collision Detection

Before we can actually start blasting away PyGame code, we need to understand how to detect collisions. There are a number of techniques for doing so. The simplest are listed here along with brief descriptions. If you want more detailed information, I suggest you check out some of the resources listed at the end of the tutorial.

  • Bounding Box Collision
  • This is perhaps the easiest and simplest 2D collision detection method available. In bounding box collision detection, we simply imagine each sprite (image) having a rectangular box around it. To detect collisions, we simply see if the bounding boxes of two sprites overlap. If they do, we flag the condition as a collision and take appropriate action. Look at figure 1 to see an example.

    bounding box collision detection

    Figure 1: Bounding Box Collision Detection

  • Bounding Circle Collision
  • A simple variant of the bounding box collision test, the bounding circle test, as the name suggests, simply replaces the boxes in the bounding box with a circle around the sprites, and sees if the circles overlap. In most simple games, a combination of both the bounding circle and box collision tests is used, depending on the sprite. If the sprite has a more circular shape, a bounding circle is used. Otherwise, the bounding box is used. Have a look at figure 2 to see an example of a bounding circle.

    bounding circle collision detection

    Figure 2: Bounding Circle Collision Detection

  • Pixel Overlap Test
  • A more exact test for collision detection, the pixel overlap test checks the pixels of both the sprites to see if any of them overlap. If they do, then we can be sure that they overlap. This method, while using more CPU time, results in very accurate collision tests.

Note that in the bounding box/circle tests, it is very difficult to find the exact point where two sprites have collided. The pixel overlap test fixes this by allowing us to find exactly where the two sprites have collided. This can result in better physics simulation. If the sprites always keep the same orientation (they are not rotated), the bounding box/circle method can give us a good approximation of the angle of collision. If however the sprites have rotated before collision, the pixel overlap test is usually the one to go with.

If you remember the last tutorial, we have already used a simple form of the bounding box collision test when we tested the sprites for collisions against the walls. The piece of code that did that is shown below:

def Update(self, scr=None):

 # start
 if self.x < 0:
  self.x = self.maxX
 if self.x > self.maxX:
  self.x = 0
 if self.y < 0:
  self.y = self.maxY
 if self.y > self.maxY:
  self.y = 0
 # end

 self.rectangle.move_ip(self.x, self.y)
 if scr != None:
  scr.blit(self.image, self.GetPosition())

The important parts are the ones between the start and end comments. While not strictly collision tests, they do provide the most rudimentary form of collision detection. This code actually tests if the sprite has moved beyond the borders of the screen and wraps it around to the other side if it has.

Our Collision Detection Engine

What we are going to do today is to create a simple billiards simulation. We will use both the bounding box and the bounding circle collision tests to see the difference in their results. What we are going to create will look like a couple of balls moving on a billiards table, colliding with the walls and each other. While not all that exciting, it may just be the start of a Snooker Club type game.

The game code is in two parts. Firstly, there is the code for the Ball Sprite class. We create this class as it makes it a lot easier to manage things. The code for the Ball class is given here. I'll explain the entire code line by line so that you get a hang of how things are done in Pygame.

class Ball:
    def __init__(self, radius=50, init_pos=(0, 0), init_speed=[0, 0], color=pygame.Color('red')):
        """
        This function creates a new Ball object. By default, the new Ball
        has a radius of 50 pixels, a starting position of (0,0), a speed
        of (0,0) and a red color.
        """

        # create Surface to hold image for both drawing and erasing the Ball
        self.img = pygame.Surface((radius * 2, radius * 2))
        self.bg = pygame.Surface((radius * 2, radius * 2))
        # fill both surfaces with the transparent color
        self.img.fill(transColor)
        self.bg.fill(transColor)
        # draw the Ball shape to both the img and the bg surface
        pygame.draw.circle(self.img, color, (radius, radius), radius)
        pygame.draw.circle(self.bg, bgColor, (radius, radius), radius)
        # set the color key for both surfaces
        self.img.set_colorkey(transColor)
        self.bg.set_colorkey(transColor)
        # convert both Surfaces for faster bliting to the screen
        self.img.convert()
        self.bg.convert()
       
        # create rectangle for the Ball image
        # give it the initial position that was passed via init_pos
        self.rect = self.img.get_rect(init_pos)
        # set the speed of this Ball object
        self.speed = init_speed
       
   
    def set_speed(self, new_speed):
        self.speed = new_speed
   
    def move(self, bounding_rect):
        """
        Moves the Ball object according to self.speed
        Takes a pygame.Rect() object in bounding_rect parameter. When moving,
        checks if the Ball is within the bounds of this rectangle. If not,
        moves the Ball to correct this situation
        """

        # check if we have a valid bounding_rect. if not, just crash the game
        # after giving an error message
        if not isinstance(bounding_rect, pygame.Rect):
            sys.exit("ERROR: Invalid type for bounding_rect parameter!\n")
        # once we have done sanity checking, continue with moving the Ball
        self.rect.move_ip(self.speed[0], self.speed[1])
       
        # now, check if the Ball is within the bounds of the bounding_rect
        if bounding_rect.contains(self.rect):
            pass # nothing to do here, as the Ball is within bounds
        else: # if the Ball is outside of bounding_rect
            # first, we find which the direction we are getting out of bounds
            if self.rect.top < bounding_rect.top:
                # from the TOP side. we move the Ball to the max top first
                self.rect.top = bounding_rect.top
                # then, we check if the Balls current Y velocity will take
                # it out of bounds again. if it will, we inverse the Y velocity
                # by multiplying it with -1
                if (self.rect.top + self.speed[1]) < bounding_rect.top:
                    self.speed[1] *= -1
            elif self.rect.bottom > bounding_rect.bottom:
                # likewise for bottom side
                self.rect.bottom = bounding_rect.bottom
                if (self.rect.bottom + self.speed[1]) > bounding_rect.bottom:
                    self.speed[1] *= -1
            # now, we do the same for the left & right side
            if self.rect.left < bounding_rect.left:
                self.rect.left = bounding_rect.left
                if (self.rect.left + self.speed[0]) < bounding_rect.left:
                    self.speed[0] *= -1
            elif self.rect.right > bounding_rect.right:
                self.rect.right = bounding_rect.right
                if (self.rect.right + self.speed[0]) > bounding_rect.right:
                    self.speed[0] *= -1
   
    def erase(self, surface):
        # erase the Ball object from its current location
        surface.blit(self.bg, self.rect)
   
    def draw(self, surface):
        surface.blit(self.img, self.rect)

Let me explain the code:

  • First of all, the lines starting with the # symbol are comments. Comments are totally ignored by the computer when running the program, and are just here to ease the understanding of the program by any one reading it.
  • In both the __init__ and the move function, the first thing you might have noticed is the explanation for the function given between the triple quotes. This is the DocString for the function. In Python, both classes and functions can have DocStrings. DocStrings are kind of like comments, in the sense that they are ignored by the Python language. However, they provide valuable information about a function/class, and are actually accessible from within a Python program, as opposed to comments which are only seen when a person views the actual code. For example, if you want to see the DocString for the move function, you write:

    print move.__doc__

  • The import statements, as you already know from the previous tutorials, imports the pygame library as well as other modules needed in the program.
  • A class definition is started like so:

    class Ball:

  • Next, we have declared a special function named __init__. This is a special function in the sense that it is called by Python automatically every time we create a new object from the Ball class. In Python classes, all functions must be passed a first argument self, which is sort of like a pointer (variable) to the object from which the function was called. As we'll see later in the code, we can call any method on a Ball object by using the syntax:

    ballObject.functionName()

    As you can see, we never pass any parameter, however, Python automatically passes the self parameter, making it point to ballObject, the object which was used to call the function. The code for the __init__ function is given below:

    def __init__(self, radius=50, init_pos=(0, 0), init_speed=[0, 0], color=pygame.Color('red')):
       """
       This function creates a new Ball object. By default, the new Ball
       has a radius of 50 pixels, a starting position of (0,0), a speed
       of (0,0) and a red color.
       """

       # create Surface to hold image for both drawing and erasing the Ball
       self.img = pygame.Surface((radius * 2, radius * 2))
       self.bg = pygame.Surface((radius * 2, radius * 2))
       # fill both surfaces with the transparent color
       self.img.fill(transColor)
       self.bg.fill(transColor)
       # draw the Ball shape to both the img and the bg surface
       pygame.draw.circle(self.img, color, (radius, radius), radius)
       pygame.draw.circle(self.bg, bgColor, (radius, radius), radius)
       # set the color key for both surfaces
       self.img.set_colorkey(transColor)
       self.bg.set_colorkey(transColor)
       # convert both Surfaces for faster bliting to the screen
       self.img.convert()
       self.bg.convert()
           
       # create rectangle for the Ball image
       # give it the initial position that was passed via init_pos
       self.rect = self.img.get_rect(init_pos)
       # set the speed of this Ball object
       self.speed = init_speed

    Most of the code should be familiar to you from the previous tutorials. First, we create two surfaces to hold the actual image of the Ball and another image with the background to erase it. In the last tutorial, we loaded an image file from disk and created a surface out of it. Here, we do things differently. Rather than use a predefined image file for the Balls, we create the Ball image in the code using pygames built-in functions. This allows us to control many aspects of the image, including radius and color. To draw a circle, we use the function:

    pygame.draw.circle(SURFACE, COLOR, CENTER, RADIUS)

    All the parameters are self explanatory. The next line of code however, needs some discussion:

    self.img.set_colorkey(transColor)

    What we are doing here is setting a 'Color Key' for the surface. A color key can be thought of as simply a transparent color. We are telling pygame that the transColor (which is previously defined as pure White) is to be treated as transparent. So, whenever pygame blits the surface, it ignores any pixels that have the same color as the color key. This is needed because surfaces can only be rectangular, while the circle we draw is, well, circular. Thus, there is a portion of the rectangular surface that would not be part of the circle. We thus tell pygame to ignore the extra region by setting the color key equal to white. This concept can take some time to understand, so an image is attached to help you comprehend.

    color key example

    We need to set a ColorKey because if we didn't, the whole surface would be blitted to the screen, and that includes the brown part. We only need the circle to be blitted, so we tell PyGame to treat the brown part as transparent and not blit it.

    Every thing else in the __init__ function is pretty much what you did in the previous tutorials.

  • The move function is quite simple. What it does is to move the Ball objects position by adding the velocity to its current position, while checking that the object remains inside a rectangular area that is passed to the function as the parameter bounding_rect. The comments are quite explanatory and I don't think require any deep discussion. The reason why I created a separate move function is that it makes things a lot simpler. Say you change the way the Ball class handles collisions with the walls, then all that you need to change is the code inside of the class itself. Nothing outside will change. This is called the concept of encapsulation and is one of the biggest benefits of using classes. The details of how the class does what it does are hidden from the code that actually uses objects of the class.
  • Now we come to the main function. The place where collisions detection is actually done. Compared to the rest of the code, the collision detection is quite simple. The code for the collision detection is given here:

    def collision_detect(ball_list, bounding_rect):
     bList = list(ball_list)
     for ballA in bList:
      # remove the current Ball object, as we do not want to test it again
      bList.remove(ballA)
      for ballB in bList:
       # check if the rectangles of the two calls are overlapping
       # since we are using bounding box collision detection, this is
       # how we test for a collision
       if ballA.rect.colliderect(ballB.rect):
        # inverse the velocity of one of the Ball objects at random
        b = random.choice([ballA, ballB])
        x = b.speed[0]
        y = b.speed[1]
        x *= -1
        y *= -1
        b.set_speed([x, y])
        # now, move the Balls away so they don't collide any more
        while ballA.rect.colliderect(ballB.rect):
        ballA.move(bounding_rect)
        ballB.move(bounding_rect)

    As parameters, this function receives a list of Balls that need to be checked against each other for collisions, and a rectangular area bounding the movement of the balls. The first thing we do is to create a variable bList that holds a copy of all the Ball objects that the function received. Next, we loop through all the Balls in the list. We check if the rectangles of the two Balls we are checking overlap in the following line of code:

    if ballA.rect.colliderect(ballB.rect):

    Pygames rectangles have a built-in function for checking if two rectangles are colliding with each other. We are simply using that function to check if the rectangles of the two Ball objects are colliding with each other. If they are, we treat it as a collision of the two Ball objects, since we are using bounding box collision test. Once a collision is detected, we chose a random Ball object from amongst the two that we are checking and reverse its velocity so that it will now move away from the other ball to avoid the collision. Next, we move one of the Ball objects until we reach a point when the two Balls are not colliding with each other anymore. While the results are not very accurate or even pretty, this is a simple way of handling collisions. If better results are required, you could change the code that changes the velocities of the balls once a collision has been detected. Everything else need not change.

  • The rest of the code just uses the Ball class and the collision detection function to create a small demo of the application. It's quite simple and you have already seen it in the previous tutorials.

Well, that's about it. If you were hoping for (or even dreading) lots of Maths, sorry to disappoint. The Math is there if you want it, its just that for demonstrating simple collision detection, we do not need to use it. Hope you find this useful.

Add Comment

Put code snippets inside language tags:
[language] [/language]

Examples:
[javascript] [/javascript]
[actionscript] [/actionscript]
[csharp] [/csharp]

See here for supported languages.

Javascript must be enabled to submit anonymous comments - or you can login.
CAPTCHA
This question is for testing whether you are a human visitor and to prevent automated spam submissions.