Introduction To Pygame

Pygame is a set of Python modules designed for writing video games and complex multimedia applications. It handles game development tasks such as rendering graphics, managing events, playing sounds, and handling user input.

Pygame’s architecture makes it particularly appealing for rapid prototyping and experimentation, as its straightforward API allows for quick implementation of game mechanics and interactive features. The library operates on top of SDL (Simple DirectMedia Layer), ensuring that Pygame applications can run on a wide variety of platforms without modification.

Resources

  • Official Pygame documentation exists and is decent.

  • This video is nearly 4 hours long but goes from zero to full game using Pygame and is very helpful in getting the gist of what it’s all about. You can find the code on its GH repo.

  • Pygame is really a polite Python wrapper around SDL (Simple Direct Media Layer). Often true insight is found reading the SDL documentation directly.

Pygame Architecture

Pygame is divided into several key modules, each responsible for a distinct aspect of game development. These include pygame.display which manages the game window and screen, pygame.image to handle image loading and rendering, and pygame.mixer for sounds and music.

Pygame Performance

If you look at examples you often see the following cryptic code.

clock= pygame.time.Clock()
while game_loop_running:
    clock.tick(60)  # limits FPS to 60

That clock tick comment suggests (as found in the most basic official example) that it is to limit the frame rate. But why would you want to do this? The real reason this clock checking is critical is that if your computer can run your code very quickly it will jump to 100% CPU continuously trying to look for any change in status as quickly as possible. But if you add this tick setting, it will realize that it’s well ahead of schedule and shut down until it needs to wake up and attend to more details at the next 17ms (in the case of 60fps) scheduled interval. So if your loop is taking .5ms to run it will just relax for 16ms or so and your CPU usage will be much more sensible.

Event Handling

There are two main ways to approach event handling, the Event Queue and querying Key States more proactively.

Event Queue

Event handling in Pygame is managed through pygame.event, which tracks and processes user inputs and other game events. Pygame can detect a wide array of input events, including key presses, mouse movements, and joystick inputs. Pygame manages an event queue, which stores all events detected during the game loop. Developers interact with this queue via the pygame.event.get() method, which returns a list of all pending events. By iterating over this list, the game can respond to each event, such as moving a character when a key is pressed or quitting the game when the close button is clicked.

Event Key States

Calling pygame.key.get_pressed() will check for keyboard key events and pygame.mouse.get_pressed() checks mouse buttons. This method returns the state of all keys/buttons at the moment it’s called, allowing you to check if a specific key or button is pressed without needing an event to be generated and placed in the queue.

The pygame.ket.get_pressed() method returns the state of all keyboard keys in a single call. This function generates a sequence (typically a tuple or list) where each element corresponds to a key on the keyboard, and the boolean value at each position indicates whether that key is currently being pressed. The sequence index corresponds to a key’s integer ID (which can be found in the Pygame key constants, like pygame.K_a for the "A" key, pygame.K_SPACE for the spacebar, etc.). You can use these constants to index into the sequence and check if a particular key is pressed. It’s essentially like accessing a dictionary where the keys are the integer IDs of the keyboard keys.

Surface Objects

In Pygame, the Surface object represents an image or any drawable area. It is the interface to the underlying SDL Surface data structure. Essentially, it’s a canvas where graphical elements can be drawn, manipulated, or transformed. Each Surface is associated with a specific width and height, and it can hold either a still image loaded from a file or dynamically generated graphics. Surfaces play a crucial role in rendering, as they can be blitted (BLock Image Transfer, basically just an efficient copy) onto other Surfaces, including the main display surface obtained via pygame.display.set_mode(). This blitting process is central to Pygame’s rendering system, allowing for the layering of images and efficient screen updates. Surface objects can be scaled and rotated as well as blitted with other surface objects.

The main game window that is visible is the Display Surface. It is initialized with pygame.display.set_mode(). When you draw on this surface, it gets rendered to the screen. You can also have off-screen surfaces that can help compose things before being swapped out to the display surface.

Besides explicitly using the pygame.Surface() class directly, loading images will also create a Surface to contain them.

racetrack= Surface( (300,200) )
player= pygame.image.load('graphics/player.png').convert_alpha()

Surfaces support transparency to help in composing complex scenes out of multiple Surface objects. Surfaces can be controlled to the pixel level. If a rectangular collision box is not fine enough, you may need to look into the pixels of the surfaces involved.

Rect Objects

Rect objects represent rectangular areas and are used for collision detection, positioning, and geometry. They are the interface to the underlying SDL_Rect data structure. In Pygame a Rect can be created from a Surface object, defining the area it covers, or manually by specifying the position and size. They are instrumental in managing game objects' positions, as they allow for easy manipulation of coordinates. Rect objects support a variety of operations, such as moving, resizing, and testing intersections with other Rects, making them important for both layout management and gameplay mechanics, such as detecting when a player character intersects with an enemy or a collectible.

Here is an extremely simple working example that loads an image file into a Surface object. This image surface generates a Rect object for positioning. The rect location is set and it is eventually blitted onto the screen surface.

#!/usr/bin/python
import pygame
pygame.init()

screen= pygame.display.set_mode((800,600))
mygraphic_image= pygame.image.load('myimg.png') # Load an image into a surface.
mygraphic_rect= mygraphic_image.get_rect() # A positioning rect for the surface.
mygraphic_rect.topleft= (100, 100) # Position rect on screen.

# Main game loop
running= True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running= False
    screen.fill((0, 0, 0)) # Blank the screen.
    screen.blit(mygraphic_image,mygraphic_rect) # Blit image to rect.
    pygame.display.flip() # Update the display.

pygame.quit()

Rect Attribute Assignment

The Rect object has attributes which can be used to move and align the Rect. Here are some examples.

rect1.right= X
rect2.bottomleft= (X,Y)
rect3.center= (X,Y)
rect4.midtop= X
rect5.midright= Y

You can also resize the Rect by assigning attributes like this.

rect1.width= X
rect2.h= Y
rect2.size= (X,Y)

And this could be a handy feature.

x,y,w,h= myrect

Rect Collision Detection

Pygame provides some support for basic but useful collision detection in the Rect class. The most basic form is to have two Rect objects and call the colliderect() method on one passing in the other. If it returns True they overlap. Here’s a basic example.

player_rect= pygame.Rect(50,50,32,32)
enemy_rect= pygame.Rect(100,100,32,32)
if player_rect.colliderect(enemy_rect):
    print("Collision detected!")

There is also a collidepoint() method that will check to see if the Rect object has overlapped some specific point. Here’s a good example of that.

mouse_pos= pygame.mouse.get_pos()
if player_rect.collidepoint(mouse_pos):
    print("Player rectangle clicked!")

There’s also a collidelist() method which checks to see if a certain rect has collided with any of a list of other objects. Here is an illustrative example of that.

enemy_list= [enemy1_rect,enemy2_rect,enemy3_rect]
if player_rect.collidelist(enemy_list) != -1:
    print("Player collided with an enemy!")

Sprite Objects And Sprite Groups

Sprites are objects that represent non-static entities in your game, such as characters or items. The pygame.sprite.Sprite class is used to create sprites, which should be extended to include image and rect attributes for graphical representation and positioning, respectively. For managing and updating groups of sprites, use pygame.sprite.Group objects. Groups enable efficient rendering and updates of contained sprites with mygroup.draw(screen) and mygroup.update(), facilitating collision detection and sprite management.

Here’s a complete example that creates a sprite group of a bunch of stars and then spins them around the center of the screen. You can see the Star class extends the pygame.sprite.Sprite class and that’s what allows this entity to be rendered with no further worry. This leaves the Star class to mostly just worry about what your game needs the stars to do (in this example, rotate).

#!/usr/bin/python
import pygame
from random import randint
from math import sin,cos

pygame.init()
width,height= 800,600
screen= pygame.display.set_mode((width, height))
pygame.display.set_caption('Rotating Starfield')
clock= pygame.time.Clock()

class Star(pygame.sprite.Sprite):
    def __init__(self,x,y):
        super().__init__()
        ss= randint(1,8)
        self.image= pygame.Surface((ss,ss)) # Set random star size..
        self.image.fill((255,255,255))  # White stars.
        self.rect= self.image.get_rect(center=(x,y))
        self.cenX= x - width//2
        self.cenY= y - height//2

    def update(self,ang): # Rotate the star around center.
        rotated_x= self.cenX*cos(ang) - self.cenY*sin(ang)
        rotated_y= self.cenX*sin(ang) + self.cenY*cos(ang)
        self.rect.x= rotated_x + width//2
        self.rect.y= rotated_y + height//2

stars= pygame.sprite.Group()
for _ in range(100):  # Create stars.
    x,y= randint(0,width),randint(0,height)
    stars.add(Star(x,y))

angle= 0
running= True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running= False
    screen.fill((0, 0, 0))  # Clear screen with black.
    stars.update(angle)  # Update stars.
    stars.draw(screen)  # Draw stars.
    angle += 0.007  # Increment radians for rotation.

    pygame.display.flip()
    clock.tick(60)  # Maintain 60 FPS.

pygame.quit()

Use pygame.sprite.GroupSingle for sprite management that could not sensibly benefit from managing multiple sprites at the same time. For example, the player’s sprite is probably not part of a collection of similar identical sprites. Really, I can’t understand why there would be a GroupSingle which sounds like an oxymoron. I think one trick that could make it useful is if you add additional sprites to the single-style "group", they will displace others instead of adding to them.

Sprite groups can be used as kind of an attribute system for sprites. It really seems like more of a way to organize things where assignment to various groups is relatively high performance than tracking some other kind of stats, for example, monsters_agro, monsters_visible, monsters_active, monsters_spawned could all be sprite groups containing the correct entities.

There are several render groups that are derived from sprite group objects.

  • RenderPlain - A group that just renders the sprites.

  • RenderClear - Clears the old ones and renders the group.

  • RenderUpdates - Clears and renders just the rects that need it.

The general approach for what inherits what to set things up looks a bit like this (incomplete) code.

class Player(pygame.sprite.Sprite):
    def __init__(self):
        super().__init__()
        self.image= pygame.image.load('/player.png').convert_alpha()

class Mob(pygame.sprite.Sprite):
    def __init__(self,type):
            super().__init__()
            if type == 'creeper': # Do creepy stuff...
            if type == 'skelly': # Do skeletal stuff...
    def update(self): # Gets called on all members upon mob_group.update()
        self.check_health()
        self.animation_stuff()

player= pygame.sprite.GroupSingle()  # Actually a kind of group.
player.add(Player())                 # Add the player class to the "group".
mob_group= pygame.sprite.Group()
mob_group.add(Obstacle('creeper'))
mob_group.add(Obstacle('skelly'))
mob_group.update()

Sounds And Music

The primary Pygame mechanism for sound effects is pygame.mixer.Sound. Here’s a basic idea of what that looks like.

self.jump_sound= pygame.mixer.Sound('audio/jump.mp3')
self.jump_sound.set_volume(0.5)
...
self.jump_sound.play() # When ready to make this sound.

This Sound class can also be used for full looped music tracks as in the following example.

bg_music= pygame.mixer.Sound('audio/music.wav')
bg_music.set_volume(0.1)
bg_music.play(loops=-1)

There does also appear to be a pygame.mixer.music class. (Why is it not capitalized like the Sound class? Don’t know.) The difference between the music playback and regular Sound playback is that the music is streamed, and never actually loaded all at once. The mixer system only supports a single music stream at once. Apparently MP3 files work but OGG files work better.

Fonts And Text

Pygame makes it pretty easy to put text on the screen. You can use True Type or OpenType fonts quite easily. Here’s an example that draws "Game Over" on the screen and then scales it up for a kind of zoom effect.

#!/usr/bin/python
import pygame
import sys

pygame.init()
screen= pygame.display.set_mode((640, 480))
pygame.display.set_caption('Font Size Example')
fontsize= 10

running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
    screen.fill((0,0,0)) # Clear screen.
    font= pygame.font.Font("myfont.ttf",fontsize)
    text_surface= font.render('Game Over',True,(255,255,255)) # Middle arg is antialias.
    text_rect= text_surface.get_rect(center=(320,240))
    screen.blit(text_surface, text_rect)
    pygame.display.flip() # Update display.
    fontsize += 1 # Increase the font size.
    pygame.time.Clock().tick(60)
    if fontsize > 300: # Stop increasing the font size at some point.
        running = False

pygame.quit()
sys.exit()

Here’s another font demonstration that prints out all of the Unicode symbols involved in music notation. It then nicely draws a grid and a helpful reference X so you know where the anchor for the symbol is. It then labels the symbol with the hex value using the default system font.

#!/usr/bin/python
# Display all of the glyphs for music symbols.
# This is in the Unicode range 0x1D100 to 0x1D1FF.
# The font used is available here:
# github.com/notofonts/noto-fonts/raw/main/hinted/ttf/NotoMusic/NotoMusic-Regular.ttf
import pygame
pygame.init()
clock= pygame.time.Clock()

WIDTH,HEIGHT= 1200,1200
ROWS,COLS= 16,16
grid_width,grid_height= WIDTH//COLS,HEIGHT//ROWS

screen= pygame.display.set_mode((WIDTH,HEIGHT))
screen.fill((0,0,0))
Mfont= pygame.freetype.Font("NotoMusic-Regular.ttf",32)
Tfont= pygame.freetype.SysFont(None,13) # Default text font.

def draw_text(F,surface,text,x,y,color):
    F.render_to(surface,(x,y),text,color)

for i in range(0x1D100, 0x1D1FF + 1): # Unicode music symbols range.
    col= (i-0x1D100)%COLS
    row= (i-0x1D100)//COLS
    x,y= col*grid_width,row*grid_height
    tx,ty=x+(grid_width//2.5),y+5
    # Draw the glyph on the screen
    draw_text(Mfont,screen, chr(i),tx,ty,(255,200,255))
    draw_text(Tfont,screen,f"0x{i:X}",tx-20,ty+55,(255,200,255))
    Xs= 5 # Center point X marker size.
    pygame.draw.line(screen,(255,0,55),(tx-Xs,ty-Xs),(tx+Xs,ty+Xs))
    pygame.draw.line(screen,(255,0,55),(tx+Xs,ty-Xs),(tx-Xs,ty+Xs))
    # Draw grid.
    pygame.draw.line(screen,(155,155,255),(x,0),(x,HEIGHT))
    pygame.draw.line(screen,(155,155,255),(0,y),(WIDTH,y))
pygame.display.update()

# == Game Loop
running = True
while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
    keys= pygame.key.get_pressed()
    if keys[pygame.K_q] or keys[pygame.K_ESCAPE]:
        running= False
    clock.tick(60) # 60 FPS and not 100% CPU.
pygame.quit()