A brief guide to Pygame

2017-06-01

For a web developer moving into games, HTML5 was a natural first choice. Doubly so for a Linux user who wanted his games to run on other operating systems without much fuss. But browser compatibility wasn't so great either (it still isn't even in 2017), and many people don't like playing games in their browser, for all the convenience it brings.

Having just discovered the joys of Python, and happening to like a game made with it -- called Monsterz -- the Pygame library was an obvious choice. It's ported to all the major platforms, well-documented, and very easy to use while still powerful. I remember seeing complaints about the Pygame community online, but my experience has been good.

One downside is that up until the recent revival Pygame only worked with Python 2.7, but then it's what Mac users get by default; I'll try to keep my code forward-compatible in case you have version 1.9.2 or newer. I was also surprised to see just how many Pygame functions I use in practice: over seventy! And that's still only part of the API.

If you happen to be on Linux or Mac, you already have Python installed, but Windows users need to get a suitable runtime from python.org; either way, you also need to install the library, either through a package manager or directly from pygame.org. Make sure you get compatible versions for both.

That done, you can check if you're ready by firing up a Python prompt and typing:

	import pygame

	pygame.init()
	screen = pygame.display.set_mode((1024, 768))
	pygame.display.set_caption("Your window title here")

If the first line doesn't raise an exception outright, you're good to go. That's really it.

Now put that code in a file so we can get started properly. (Just don't name it pygame.py: it will try to import itself instead of the actual library.) The window will appear as before, but close right after as the script ends. That's normal, but it does mean our first priority is to set up an event loop:

	clock = pygame.time.Clock()

	while True:
		delta_t = clock.tick()
		event = pygame.event.poll()
		if event.type == pygame.QUIT:
			break

There you go. Now the Pygame window will stay on screen until you close it. I say the window because Pygame, being based on the SDL library, only supports one at a time. An optional argument to set_mode can instruct it to try and open in full screen, but I never cared for that. You could also wait rather than poll for an event, but so far all my Pygame titles have been the realtime kind. Last but not least, clock.tick can take an optional argument, say 60, to limit the framerate; the default (and maximum, I suspect) appears to be 200. The return value is time elapsed since last frame, in milliseconds: a minor, but useful convenience not often seen. Speaking of time, another useful function is pygame.time.wait(milliseconds).

For now, however, we can't tell the game is running. We need to show something on screen, and a good choice would be the framerate. Which in turn requires a bit of preparation. Add this to your initialization code:

	BLACK = (0, 0, 0)
	BLUE = pygame.Color("#00ccff")

	font_name = pygame.font.get_default_font()
	font = pygame.font.SysFont(font_name, 24)

That's two different ways to declare colors; the second way has some extra features as well, if you need them. And of course you can also load your own fonts with pygame.font.Font("myfont.ttf"), size), but being able to get a default from the operating system can be very useful for quick prototyping, and other purposes.

Either way, now you can add this to the game loop:

	screen.fill(BLACK)
	screen.fill(BLACK, (0, 0, 1024, 768))

	fps = round(clock.get_fps())
	surface = font.render(str(fps), False, BLUE)
	screen.blit(surface, (0, 0))

	pygame.display.flip()

Lots of things happening in the above code. Filling the screen with a solid color is clear enough, and so is updating the display at the end, so we can actually see what we've drawn. But in the middle, we have to render the freshly obtained framerate onto a new drawing surface -- essentially an image. At last we can put text on the screen. Which, by the way, is also an ordinary surface, as returned by set_mode.

In the way of details, the second argument to font.render controls antialiasing, and an optional fourth argument can supply a background color instead of transparency. You can also get the size of the rendered text, to compute a different position on the screen, as I'll show later. But there's a simpler way:

	screen.blit(surface, surface.get_rect(topright = (1024, 0)))

The get_rect method returns the bounding box of the surface as an instance of pygame.Rect anchored from a given position; other possible arguments include midtop, midbottom, or the good old center. Other functions that return bounding boxes include drawing primitives, that we'll look at in a moment. Or you can create and manipulate them directly:

	bbox = pygame.Rect(x, y, width, height)
	bbox.center = (new_x, new_y)

For now, let's point out that while the framerate we have right now is more than respectable, that's while doing basically nothing. See, filling a screen is in fact quite slow; we can speed things up by only doing it once, on another surface, and blitting that:

	background = pygame.Surface(screen.get_size())
	background.fill(BLACK) # The bounding box is optional here.
	
	...
	
	screen.blit(background, (0, 0))

Well, blitting used to be faster in older versions, now I'm not seeing it anymore. Use whichever works best for you. You'll need this second method anyway if you're loading your background from an image on disk:

	image = pygame.image.load("image.png") # Also a Surface object.

You can, of course, also draw on a surface with primitives such as:

	bbox = pygame.draw.circle(screen, color, (x, y), radius, thickness)
	bbox = pygame.draw.rect(screen, color, bbox, thickness)
	bbox = pygame.draw.line(screen, color, (x1, y1), (x2, y2), thickness)
	bbox = pygame.draw.polygon(screen, color, [(x1, y1), ...], thickness)

As mentioned above, all of them return their bounding box as a Rect object. The optional thickness is how many pixels wide to make the line; if zero (default), the shape will be filled instead.

Last but not least, you can also temporarily restrict drawing to part of your screen, such that anything spilled outside the boundary is never rendered:

	screen.set_clip((256, 192, 512, 384))

Use screen.set_clip(None) to stop clipping when done.

Of course, it's not much of a game if you can't interact with the pretty graphics. The good news is, in Pygame you can handle all input devices through the same event mechanism. You've already seen pygame.QUIT. Other event types include:

The first two are straightforward: any mouse event has a pos field -- a tuple of x/y coordinates, while keyboard events have a key field that you can test against constants defined in the pygame module:

- K_UP, K_DOWN, K_LEFT and K_RIGHT for the arrow keys; - K_q, K_w and so on for the letter keys (note the lower case letter); - K_ESCAPE, K_SPACE, K_MINUS, K_EQUALS as a few examples of special keys.

Gamepads are a little more involved. For one thing, you first have to check how many are connected, initialize those you want to use and make sure they have enough axes:

	if pygame.joystick.get_count() > 0:
		joystick = pygame.joystick.Joystick(0)
		joystick.init()
		axes = joystick.get_numaxes()

Now you can read each analog stick with joystick.get_axis(0) and so on (each pair of axes normally corresponds to one stick). A D-pad however is treated as a whole: x, y = joystick.get_hat(0) reads both axes at once. Last but not least, you can use joystick.get_button(0) etc. to check which buttons are depressed at any one time. Unlike in plain SDL, the analog axes return floating point values ranging from -1 to +1.

One more thing about events: because they can be of many types, some of which fire often, it's a waste of CPU cycles to detect each and every one only to discard most of them. So Pygame gives you two different ways to filter them:

	pygame.event.set_allowed([pygame.KEYDOWN, pygame.QUIT,
		pygame.JOYHATMOTION, pygame.JOYBUTTONDOWN])
	pygame.event.set_blocked(pygame.MOUSEMOTION)

You can only use one of `set_allowed` and `set_blocked` at any point. Both can take either a single event type, or else a list of them.

That was fast. But I can't conclude without telling you how to get sound into your games. As it turns out, that's a couple of lines for each sound effect:

	sound = pygame.mixer.Sound("mysound.ogg")
	sound.play()

There's no need to worry about channels, the pygame.mixer module juggles them automatically (though you can help, see below). Music however is handled separately:

	pygame.mixer.music.load("mymusic.ogg")
	pygame.mixer.music.queue("moremusic.ogg")
	pygame.mixer.music.play()

You can use the `queue` function to grow your playlist at any point, even while music is already playing. However, calling load again will discard any queued songs and start over.

As usual for audio, any loaded file is played back at maximum volume by default. You can change it to a different value for the music and each sound effect with set_volume, with the range going from 0 to 1:

	pygame.mixer.music.set_volume(0.5)
	sound.set_volume(0.5)

One last thing: by default, the Pygame mixer uses a sample rate of 22KHz. If you know your audio files are CD quality and want to take advantage of that, insert the following line before the call to pygame.init():

	pygame.mixer.pre_init(frequency = 44100)

The pre_init function also takes a channels keyword argument, that you can use to change the default limit of 2. But in practice the library can juggle them well enough without help.

Conclusions

I abandoned Pygame after making only three games with it, for a couple of reasons: one, back in 2014 the library and website both seemed to be unmaintained, which cast their future into doubt; and two, because as it turns out packaging a game with the library for distribution -- never mind a complete Python runtime -- is a bother, and asking people to install it themselves will chase away most potential players who aren't making games with it in turn. Which is a problem if you plan to make products for sale. But development has resumed in December 2016, as for the commercial angle, not everything we make should be merchandise. I've certainly played enough games for free in my life that giving something back is only fair. So this particular way to make games is definitely back on my radar. Give it a chance.