MrValdez's Blog

Game Engine development: Pygame - Part 4

Posted on Nov. 22, 2018

Last time, we added some pew pew to our game. Now, let's add some text, bad guys, and collision detection.

Rendering text

First thing we need is the font. We can use the font installed in the OS or use our own.

# main.py
[...]

pygame.init()
screen = pygame.display.set_mode(resolution)
clock = pygame.time.Clock()
font = pygame.font.SysFont("Courier New", 18)

[...]

while isRunning:
[...]
    text = font.render("Hello world", False, (0, 0, 0))
    screen.blit(text, (0, 0))

    pygame.display.flip()

font.render("Hello world", False, (0, 0, 0)) allows us to print the string "Hello world", without antialiasing, and with the color black. Then we blit the text on screen.

That's basically it.

But what if we want to use our own font? For this example, I'll use the Philippine writing system Baybayin. I downloaded a font here and reviewed the writing here and here.

# main.py
[...]

font = pygame.font.Font("Baybayin_Ber.ttf", 120)
[...]

    text = font.render("Kmus=t Py=tun= Pilipin=s=", True, (0, 0, 0))

Pygame only support the low level rendering of text. So by default, you don't have newlines, text wrapping, text alignment, etc. It's up to you to make this. ...or just use a module someone created: pygame-text. Pygame-text supports the above features and more. Check it out.

For simplicity, this tutorial won't use pygame-text. I want this series to focus on what pygame provides. That said, once you understand pygame, I highly recommend using pygame-text (or similar libraries) to simplify your game development.

Spawning bad guys

Let's spawn some bad guys. Open up gameobject.py and add this

class Enemy(GameObject):
    def __init__(self, pos):
        super().__init__("enemy.png", pos)

        self.speed = random.randint(10, 50)

    def update(self):
        self.pos[0] -= self.speed

Basically, this enemy just move from right to left, with each enemy having different speed. Oh! We forgot to draw the bad guy. Here:

You know he's evil because he have a goatee and is mirrored!

Next, we want to spawn the enemy in main.py:

# main.py
import os
import pygame
import random
from gameobject import Shot, Hero, Enemy

[...]

hero = Hero([0, 0])
shots = []
enemies = []
world = [[hero], shots, enemies]

def spawn_enemy():
    pos = [random.randint(3000, 10000), random.randint(-10, resolution[1] - 100)]
    enemy = Enemy(pos)
    enemies.append(enemy)

for i in range(10):
    spawn_enemy()

while isRunning:
[...]
    text_str = f"Enemies in game: {len(enemies)}"
    text = font.render(text_str, False, (0, 0, 0))
    screen.blit(text, (200, 200))

    for object_group in world:
        for object in object_group:
            object.update()

    for object_group in world:
        for object in object_group:
            object.draw(screen)

    pygame.display.flip()

We've added a new list to hold all the different game objects. Some game engines have a system to automatically assign newly spawn objects into a category (player controlled characters, friendly NPCs, enemies, attacks, HUD, etc). We will be exploring these systems in a future chapter. For now, let's manage the game objects ourselves.

We spawn our enemy outside our game area and have them come in from the right.

Btw, f-strings only works from Python 3.6 onward. If you are using Python 3.5 and below, change the line to

text_str = "Enemies in game: {}".format(len(enemies))

Finally, we refactored our update and draw to use our new game world list.

You might be wondering why we separated the update and draw steps. Why not combine them?

The answer is simple: Once we add a game world update, there's a possibility that a game object's state will be changed. If we drew that object before the game world updates, we might get visual artifacts.

Now, let's run the game:

Looks exciting. ...almost. .....let's change this value

for i in range(1000):
    spawn_enemy()

Hmmmm. That went exactly how we have expected. For our sanity and to lower the gameplay difficulty (we can adjust later), let's change the value to 50.

Play around with the spawning enemies with this commit.

Collision detection

The game now looks like a game... but it isn't acting like one. Game objects just pass through each other with no interaction. A game is fun if the player can interact with the game world. So let's add that.

First thing first, there are many collision detection techniques such as rectangle collisions, sphere collisions, and per-pixel checks. Per pixel checks are the slowest as you will be checking each pixel of two different objects to see if they collided. Rectangle collisions are the fastest as it only involves 4 condition checks and some additions.

Rectangle collision detection is possible if we represent each game object as a rectangle. We then take each object's rectangle and compare between them.

The maths for collision detection is as follows:

box1.x + box1.width >= box2.x and
box1.x <= box2.x + box2.width and
box1.y + box1.height >= box2.y and
box1.y <= box2.y + box2.height

At 11:13 in this presentation, I demonstrated the algorithm in real time. Note: I'll upload a demo just like this at the end of this chapter, so you can try it out yourself.

Let's try this out:

# main.py
[...]

def check_collision(box1_pos, box1_size,
                    box2_pos, box2_size):
    return (box1_pos[0] + box1_size[0] > box2_pos[0] and
            box1_pos[0] < box2_pos[0] + box2_size[0] and
            box1_pos[1] + box1_size[1] > box2_pos[1] and
            box1_pos[1] < box2_pos[1] + box2_size[1])

def update_world():
    for shot in shots:
        for enemy in enemies:
            has_collided = check_collision(shot.pos, shot.image.get_size(),
                                           enemy.pos, enemy.image.get_size())
            if has_collided:
               enemies.remove(enemy)

while isRunning:
[...]

    for object_group in world:
        for object in object_group:
            object.update()

    update_world()

We put the algorithm into a function since its easier to read function names than the raw algorithm. We extract the size of our sprite with get_size().

We also made an update world function. So any changes in the game world are done here. For now, updating the world only involves checking if a shot hits an enemy. If True, the enemy is removed from the world.

Let's not forget calling the update function in our game loop.

This is what our game looks like now:

One thing that you might notice is that the shot stays in the game even after colliding with the enemy. Also, the shot will kill any enemy it hits off-screen. This is because shots and enemies exist even when you can't see it. Let's fix this with the following changes:

def update_world():
    for shot in shots:
        if shot.pos[0] > resolution[0]:
            shots.remove(shot)
            continue

        for enemy in enemies:
            has_collided = check_collision(shot.pos, shot.image.get_size(),
                                           enemy.pos, enemy.image.get_size())
            if has_collided:
               enemies.remove(enemy)
               shots.remove(shot)
               break

Take note that we iterate the next shot right after we remove the current shot we are checking. This is to prevent any additional interaction with other game objects.

The order of updates is important and it'll depend on the kind of game you're making on what should be processed first.

You can play this version in the commit here.

One last thing: Did you noticed that our collision isn't accurate? Let's slow down the shot and enemy speed so you can see:

The shot haven't touched the enemy yet and it still got hit! This looks like a bad hitbox, a very common situation in video games (sometimes intentional, but that's a story for later).

The bad hitbox becomes clear if we review our current sprites (I added a border to make the hitbox clearer):

The image is actually bigger than our sprite. Let's trim the sprite to the exact edges:

This should make the game look less buggy. If you want to download and play with this, use this repository commit.

Detecting key down, key hold, and key up

There's a subtle bug in our current game. If you played with the code since we added our projectile attacks, you will notice it.

But for those who are not following with the code, in the video, I only press the spacebar once and released as soon as I can. To make it clear, I put in some debug text (I'll show how to do this later in this section):

While I only pushed the spacebar once, the game interpret that as holding the button for multiple frames. So while it looks like that's only one shot, its actually multiples.

What we need is to detect if the button is push down, held, or push up. Unfortunately, pygame doesn't have a high level code for this - we have to make our own.

The trick is to keep track of the state of the keys each frame. Let's make a new class for this:

# main.py
[...]
class Input():
    def __init__(self):
        self.prev_keydown = self.current_keydown = pygame.key.get_pressed()

    def update(self):
        self.prev_keydown = self.current_keydown
        self.current_keydown = pygame.key.get_pressed()

    def is_hold(self, key):
        return self.current_keydown[key]

    def is_down(self, key):
        return self.current_keydown[key] and not self.prev_keydown[key]

    def is_up(self, key):
        return not self.current_keydown[key] and self.prev_keydown[key]

input = Input()

while isRunning:
[...]
    input.update()
    if input.is_down(pygame.K_SPACE):
        new_shot = Shot(hero.pos)
        shots.append(new_shot)

Side note: Some pygame examples and tutorials check the pygame.event for key up events. There's nothing wrong with this approach and if it works for you, go ahead and use it. I personally prefer not using pygame's event queue as I want to be more flexible on when and where my game state changes.

In this code, is_hold() basically does what we've already been doing - as long as the button is held down, this returns True.

is_down() checks that the in the previous frame, the key is not held down; but in this current frame, the key is pushed down. Or explained in another way: the button wasn't pushed before, but it is now.

is_up() is the opposite and checks that the button was released when it was pressed in the previous frame.

To make this clear, I added the following debug output. I've also slowed down the game to make the frames last longer and easier to see:

# main.py
[...]
while isRunning:
    screen.fill((255, 255, 255))
    tick = clock.tick(8)

[...]
    button_hold = "Yes" if input.is_hold(pygame.K_SPACE) else "No "
    button_down = "Yes" if input.is_down(pygame.K_SPACE) else "No "
    button_up = "Yes" if input.is_up(pygame.K_SPACE) else "No "

    text_str = f"Button held: {button_hold}    Button down: {button_down}    Button up: {button_up}"
    text = font.render(text_str, False, (0, 0, 0))
    screen.blit(text, (10, 0))

You can try this debug version in this repository.


Next time, we will add a second character controlled by mouse input, draw some circles and talk about particle systems. Also, maths. Lots more maths.

Categories: gamedev