ICS 32 Winter 2022
Notes and Examples: PyGame Basics


Writing a visual game in Python

It is likely that you've played some kind of visual game — on a computer or a video game console — and, as you learn more about writing programs, you might rightly be wondering how you might go about building your own game. So-called "Triple-A" games with their huge teams of artists and software developers (and the giant budgets to pay all of them) are obviously out of our reach at this point, but there is still something to be said for games that are small and simple; a lot of fun has been had (and even a fair amount of money has been made) with relatively simple games, particularly with the rise of smartphones, where games like Angry Birds captivated millions of people.

When I was a kid, game programming of almost any kind generally required a fairly extensive skill set. The tools being what they were at the time — machines much less than 1/1000 as powerful as today's inexpensive smartphones — it was necessary to play almost every low-level trick in the book to build even a simple game. The earliest games that ran on the Atari 2600, for example — a video game console I had as a young kid — had to be written with a grand total of four kilobytes of code (and assets, such as images and sounds) for an entire game. Games weren't written in high-level languages like Python; they were written painstakingly, one low-level machine instruction at a time. It was necessary to know every detail of how the hardware worked, because only then could you find a way to exploit its spartan abilities enough to make a playable, fun game run on it.

Today, though, we have an embarrassment of riches. Even inexpensive devices are fast, with comparatively abundant amounts of memory, and near-ubiquitous access to relatively fast networks. That the machines are more powerful leaves us able to use higher-level languages (like Python), even though we might incur a performance cost for using them, because we don't need to push our machines to the limit to run simple games anymore. (That said, there are still a lot of games that reach the limits of what our hardware can do, and that still requires a depth of knowledge to accomplish; the difference is that we don't need to reach the limits just to write a game like Atari's Combat anymore.)

It's certainly possible that you might see games as frivolous, but it's fair to say that they are actually nothing of the kind. They provide a fascinating combination of problems to be solved across many different fields of study: software engineering, human-computer interface, computer networks, psychology and cognition, and even (in multiplayer online games) economics and sociology. Game developers push the envelope — in some cases, further than just about any other kind of software developers — and many of these lessons can be applied in more seemingly serious contexts. In short, games have a lot to teach us about software, so we should explore what they have to offer.

What do we need?

When considering how to write a visual game in Python, the first question we need to answer is What do we need? What functionality will we need in order to be successful? What's available in Python's standard library to help us? What third-party libraries fill the gaps, when the standard library doesn't get us where we want to go?

Here are a few of the things we need to be able to do:

Few of these things are handled well in Python's standard library, as it turns out. The standard library does have a random module that can help us with the random-number generation, but as soon as we get into other areas, we find it to be lacking.

Fortunately, we're not limited only to what's in Python's standard library. And there is a fairly full-featured library called PyGame, which we learned how to install in a previous set of notes, that can do the rest of the things we're looking for.


Getting started with PyGame

Like any large library, it can seem daunting to start learning about PyGame, but the best way to do it is to start with something simple and work your way up. There are some basic concepts to learn, and most of the rest of the library is just details — and the nice thing about the fine-grained details is that they can be looked up in documentation, once you understand the concepts.

The PyGame library is extensive enough that it is actually divided into various sections, with each of those sections focused on one kind of functionality you'd need to write a game. Not all games will need all of the sections, but any of them will be available once you've downloaded PyGame; as you discover a need for one, you can feel free to use it.

Initialization and shutdown

PyGame requires initialization at the beginning of your program and shutdown at the end. This is needed to set up various internal data structures, load and configure the necessary components, and so on. Each is easy to do:

These functions are both part of the pygame module, which you would need to import before you could use it.

All of these facts give rise to the simplest imaginable PyGame-based program you could write.

import pygame


def run() -> None:
    pygame.init()
    pygame.quit()


if __name__ == '__main__':
    run()

Running this program doesn't turn out to be very interesting, as you'll see no visual indication that it's doing anything, except it may take a couple of seconds to perform the initialization. To make it more interesting, we'll need a surface to draw on, and we'll need it to be visible by making it the display.

Creating a display with a surface

The pygame.display module contains functions that let you control how PyGame displays graphical output. (Note that you won't need to import "submodules" of pygame such as pygame.display yourself; one of the things that happens when you initialize pygame using the pygame.init() function is that all of the necessary modules are imported and initialized for you automatically.)

The first of these functions that we'll need is called pygame.display.set_mode(), which is how you create a new window. It takes one argument, which is a two-element tuple that specifies the width and height of the window, measured in pixels.

import pygame


def run() -> None:
    pygame.init()

    # Creates a window containing a surface that is
    # 700 pixels wide and 600 pixels tall.
    surface = pygame.display.set_mode((700, 600))

    pygame.quit()


if __name__ == '__main__':
    run()

If you run this program, you'll see that it's similar to the one we wrote before — it still starts and ends on its own and doesn't run for very long — but that it pops up a window that subsequently disappears. The window is around 700 pixels wide and 600 pixels tall, and shouldn't display anything but a black background.

If we want something to appear in the window other than its default background color, then we'll need to draw something. The pygame.display.set_mode() function doesn't just cause a window to pop up; it also returns an object called a surface. A surface is an object representing an image that can be displayed by PyGame, so a lot of what we'll be doing, when we want to control how graphics are displayed, is manipulating these surfaces. The surface returned from pygame.display.set_mode() is the one that represents the area within our newly-created window.

The game loop and events

Games like the ones we'll be building have at least a couple of things they need to be dealing with, seemingly simultaneously.

PyGame provides tools that make it easy for us to organize these things within a single loop, which we'll call the game loop. Many times per second, we'll do two things:

PyGame provides tools for doing these things (and for controlling the frequency of our game loop, so it won't do unnecessary work). However, it should be understood that it's our loop; we're the ones who will need to write it. PyGame provides the tools to help us build our game, but we're still the ones fundamentally structuring it.

Since our game loop is one that runs for as long as our game runs, it won't be a for loop; we don't know ahead of time how many times it will run. And we'll need a way to get out of it when we know our game is supposed to end, so we'll use a boolean variable to keep track of that.

import pygame


def run() -> None:
    pygame.init()

    surface = pygame.display.set_mode((700, 600))

    running = True

    while running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        pygame.display.flip()

    pygame.quit()


if __name__ == '__main__':
    run()

What we have now is the basic game loop for a PyGame-based game. A few things are going on here:

Running this program, we'll see a window that pops up, with a black background, but which will remain visible until we close it (using a standard technique for our operating system, such as clicking the "X" in the top right, if we're running on Windows).

Colors

If we want to draw something in our window, we'll first have to understand a little bit about colors, because everything has to have a color if we want it to be visible. Colors are represented in PyGame as pygame.Color objects, each of which stores three values:

The idea of expressing colors as a mixture of red, green, and blue is not unique to PyGame; it's a fairly standard approach known as RGB. While there are other approaches to mixing colors, RGB has the benefit of being easy to understand, especially if you've ever mixed colors (paints, for example) before. (Most people are unsurprised that mixing equal amounts of red and green gives a shade of yellow, for example.)

Constructing a color in PyGame is a simple matter of constructing a pygame.Color object and passing three arguments to its constructor: the amount of red, green, and blue, respectively. For example, we could create an object representing the brightest red by saying pygame.Color(255, 0, 0) (the maximum amount of red, no green, and no blue). We could create a bright yellow as pygame.Color(255, 255, 0). And so on.

There are lots of tools available online to allow you to mix colors. One that I've found easy to use is called Color Picker, which you can fairly easily figure out how to use by playing around with it a bit. The key thing is knowing how to extract the color you chose from it. For our purposes, what we want is the amount of red, green, and blue, expressed as a number from 0-255; those will be displayed in the boxes marked R:, G:, and B:, respectively. In the example below, the color we chose would be represented in PyGame as pygame.Color(31, 133, 222).

Color Picker Example

Drawing on our surface

If we want to display something other than a black background, we'll need to change our surface during our game loop. One way to do that is to fill the surface with a single color. Suppose we updated our game loop to look like this — leaving the rest of our program the same.

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    surface.fill(pygame.Color(31, 133, 222))

    pygame.display.flip()

The surface.fill() function does what it sounds like: it fills an entire surface with one color. Our window's background will now be orange instead of black, because we've done the two things necessary to change it: update the surface belonging to our window (the one returned from pygame.display.set_mode()) and "flip" the display to make the latest changes to that surface visible.

We could make our color change over time instead by driving the color with variables instead of always filling the surface with a constant color.

color_amount = 0

while running:
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    color_amount = (color_amount + 1) % 64
    surface.fill(pygame.Color(color_amount, color_amount, color_amount))

    pygame.display.flip()

Each time we run through our game loop, we're adding 1 to an amount of color that we're using as the amount of red, green, and blue. (Whenever red, green, and blue amounts are the same, what you'll get is a shade of gray. All 0's will be black, all 255's will be white, and everything in between will be some shade of gray in between.) By using the % operator to keep the value in the range 0..63, we're keeping the effect from being too overwhelming, but it's still much too wild; it would be nice if the change could become more gradual. The problem is that our game loop is running as fast as it possibly can, so the number of color changes per second is limited only by the speed of our machine. Instead, it's time we started controlling the rate at which our game loop runs.

Clocks

PyGame provides a kind of object called a clock, which can help you to limit your game loop's frequency. Each time a clock is "ticked," it checks the time it was last ticked, then waits until the difference between the current time and the time it was last ticked is the desired amount. The effect of doing this in each iteration of your game loop is to control the number of loop iterations per second. Since we're drawing one picture per loop iteration, this has the effect of limiting that number of pictures. The common terminology in animation is to say that each picture we draw is a frame, so the common name for the number of pictures drawn each seconds is a frame rate.

The constructor pygame.time.Clock() is used to construct a clock. The tick() method can be called on the resulting object to insert the appropriate delay. The argument to tick() is not the duration of the delay, but instead is the desired frame rate; so, for example, if you pass it 30, it will wait long enough that the next iteration of your game loop would start 1/30 of a second after the previous one.

color_amount = 0
clock = pygame.time.Clock()

while running:
    clock.tick(30)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    color_amount = (color_amount + 1) % 256
    surface.fill(pygame.Color(color_amount, color_amount, color_amount))

    pygame.display.flip()

We're now oscillating between the 256 shades of gray, about 30 shades per second. So our window's background should go from being black to being white in a little over eight seconds, then revert immediately to black again and repeat.


Drawing shapes on our surface

The pygame.draw library allows us to draw lines and shapes on our surface. If we draw different shapes, or the same different shapes in different places, in each frame of our animation, we'll be able to show some fairly smooth-looking movement.

Coordinates

First, we need to understand how to describe positions within our window. We've seen before that we create our window to have a width and a height measured in pixels. Each pixel is effectively a tiny rectangle, and each can be filled with a single color. Depending on your machine (and how it's configured), there might be millions of them available to work with. For example, the laptop computer I used to write these notes has 1,920 pixels across and 1,080 down, for a total of just over 2,000,000 pixels; this is not out of the ordinary nowadays.

The important question, though, is how to address each pixel individually. PyGame uses the same coordinate system that has long been typical in two-dimensional computer graphics, which will differ somewhat from what you might have become accustomed to when you learned algebra. In two-dimensional computer graphics, it's customary for a particular point to be described by an ordered pair (x, y) of coordinates, just as in algebra. However, the origin point (0, 0) is generally the top-left corner, with x-coordinates increasing as you move to the right, and y-coordinates increasing as you move down. So, for example, on an area measuring 500 pixels wide and 400 pixels tall, the coordinate system will typically look like this.

(0, 0) (499, 0)
(0, 399) (499, 399)

As we'll see, when the window might be resized, it gets a little trickier to get exactly what we want (e.g., resizing the image proportionally when we resize the window); we'll see how to work around that issue in a later example.

Drawing a circle

We can draw a circle on a surface by calling the pygame.draw.circle() function, which takes four arguments: the surface on which we want to draw the circle, the color that should be used to fill the circle, a two-element tuple containing the x- and y-coordinates where the circle should be centered, and the radius of the circle.

For example, we could add a yellow-filled circle to our current example by adding this line into our game loop:

    pygame.draw.circle(surface, pygame.Color(255, 255, 0), (350, 300), 100)

The order in which we draw things is important; we'd need to draw the circle after we fill the surface with a color, or else filling the surface will "paint over" our circle. (In general, we have to draw in layers, with things we want to appear "above" other things drawn later — the technique of considering how things overlay on top of each other on a display is sometimes called z-ordering, so named because we respect a third dimension, depth, which we could refer to as the z-dimension to separate it from the x- and y-dimensions we've seen already.)

Adding that one line of code — just after the call to surface.fill() — will have the desired effect; we'll now see a never-changing circle filled with yellow, with the background color changing around it. Because we centered the circle at (350, 300) — which would be the center of the window, given that it is 700 pixels wide and 600 pixels tall — the circle will be centered within the window.

Of course, we could change the circle from one frame to the next, as well, if we wanted to animate it. If we draw each animation frame differently from the one before it, that's the nature of a moving animation; it really is that simple. For example, we could vary its position by centering it in a different location, or we could vary its size by changing its radius.

color_amount = 0
clock = pygame.time.Clock()
circle_center_x = 350
circle_center_y = 300

while running:
    clock.tick(30)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False

    color_amount = (color_amount + 1) % 256
    circle_center_x -= 1
    circle_center_y += 1

    surface.fill(pygame.Color(color_amount, color_amount, color_amount))

    pygame.draw.circle(
        surface, pygame.Color(255, 255, 0),
        (circle_center_x, circle_center_y), 100)

    pygame.display.flip()

One thing to note, which we'll return to in our next example: As our game loop has become more complex, it's getting harder to wrap our minds around its entire effect. But it's actually got a fairly predictable structure, so we might be wise to break this up into smaller functions as it grows in complexity.

That general structure will recur throughout our examples, and is a fairly standard way to think about the game loop of the kinds of games we'll be experimenting with in this course.


Where to find more information about PyGame

When you want to know more about the PyGame library, a good place to start is the Notes and Examples on the course web site, where I've tried to outline the big-picture concepts and demonstrate a few examples. Most likely, though, there will be things that you run into that don't happen to covered in those notes, so you'll also want to be able to navigate PyGame's online documentation, which is actually extensive and quite good. You can find that documentation at the link below.

The documentation is organized into sections for each part of the library; you'll see links to those sections listed near the top of the page. For example, if you're unsure about some part of the pygame.display library, you'll find a link titled display. I generally find myself using those navigation links near the top when I want to jump around and look at the details of things I don't remember or haven't seen yet.

There are also some tutorials available, though you'll find that they can take you into a lot of places where we won't need to go for our work.


The code

A complete version of the example that we developed throughout this set of notes is available below.