Blake O'Hare .com

PyGame Tips & Tricks

PyWeek 15 starts in one week. So here's a few tips and tricks for a more lovely pygame coding experience.

Image Cache

Use a string->Surface dictionary as an image cache. Wrap this cache with a function called get_image(path). This path will be the key for the cache. If the image isn't found in the cache, load the image from file, but convert the path delimiters. i.e. real_path = path.replace('/', os.sep).replace('\\', os.sep). This way you can just use slashes or backslashes everywhere in your code without cluttering it with a bunch of unsightly os.path.join's.

Caps in file names

If you do your primary development on Windows, adopt a consistent convention for using caps in filenames. Ideally DO NOT USE CAPS AT ALL. Make all files lowercase. All other major OS's have case-sensitive file paths. Be mindful of this.

Use Spriting

There is a HUGE overhead to loading an image from a hard drive. Loading a giant image is only slightly slower than loading a tiny image. If you have billions of tiny images, write a script that combines all the images into one giant image and generates a manifest that describes where each file is on this giant image and its size. In your game, add code to your image cache function that blits this (cached) giant image onto a small empty surface that is the size of the image you want. This way you can load your billion tiny images with only calling pygame.image.load once.

(For this example, assume there is some sort of manifest data structure that is keyed off the filename and contains a position and size field for each file. The implementation of such a datastructure should be trivial.)

_images = {}
_img_manifest = read_manifest('image_manifest.txt')
_sprite_sheet = None
def get_sprite_sheet():
  global _sprite_sheet
  if _sprite_sheet == None:
    _sprite_sheet = pygame.image.load('sprite_sheet.png')
  return _sprite_sheet

def get_image(path):
  img = _images.get(path)
  if img == None:
    img_data = _img_manifest.get_image_data(path.replace('/', os.sep).replace('\\', os.sep))
    position = img_data.position
    size = img_data.size
    img = pygame.Surface(size)
    img.blit(_sprite_sheet, (-position[0], -position[1]))
    _images[path] = img
  return img


One Loop to Rule them All

Only write one game loop. Each logical scene should be an object.

Abstract Raw Input

In this single game loop, abstract the raw input from the framework into logical input that is relevant for your game. Convert the pygame events into MyEvent, a class you create. This class will have event names such as "left", "right", "jump", "shoot" instead of K_LEFT, K_RIGHT, K_UP, K_SPACE. This mapping ought to occur based on some sort of pygame event -> custom event dictionary. This gives you the option of later creating an input configuration menu where the user can edit these values. The rest of your code should be completely unaware of the notion of pygame events. Only logical events.

Joystick

Here be monsters.

Music

Through the complexity of your game and unique ways to hit certain code, it's a common error to get into a situation where the wrong music is playing because the user somehow bypassed a crucial mixer.music.play call. For each logical scene in your game, write a function called ensure_playing(song) that gets called EVERY frame. This function should maintain a state of what song is currently playing and no-op if the input matches that. If not, switch songs.

Abstract the complexities of creating and caching text

Write a function called get_text that takes in the text, font name, color, size, etc. This should return an image that matches this criteria from either a cache or generate it if not present in the cache. This cache should be keyed off the inputs of the function. For example, if you use a *args as the inputs, you can use this tuple directly as the key. Or construct the tuple manually from rigid arguments. If you have a game with lots of dynamic text, clear this cache periodically.

For Loop Bad

Use while loops instead of for loops when iterating through a simple range of numbers. The range function wastes a ton of memory. The xrange function isn't that great either since it's wasted time to call function, push stack info, etc. A simple while loop is extremely fast by comparison.

Update: So I was totally wrong about this. See the wonderful investigation Omni did in the comments section.
Basically, I projected my experience in other languages to Python where iterators use a tad more CPU than a simple integer-incrementing-loop and made a false assumption. Python (both 2.x and 3.x) are smart enough to optimize range out and basically give you the power of a highly optimized loop. Just be sure to use xrange in 2.x.


Don't reblit identical information each frame

If you know something is guaranteed to look the same each frame, then composite multiple blits into one image that's cached and blit that one image. There is quite a bit of overhead to blit. This is especially useful if the blitted region has a number of overlapping blits or if there's complexity to generate the information that needs to be blitted.

Lists Declared Inline Kill Kittens

Do not declare lists or tuples inline in code unless you really need to create a new, separate instance. Declare them once in some sort of initialization function and refer to it that way.

Use Source Control

Even if you're working alone, source control has benefits. It saves the state of your code if when you screw it up and need to revert back to a previous version. For me, personally, it helps me focus on one feature at a time without leaving something hanging with a TODO as I am more mindful when I have to submit complete changelists.

3.x is not your enemy

Embrace 3.x. PyGame tends to run noticeably faster on it. Seriously. And whether or not you plan on using 2.x till the day you die for principled reasons, over time people will be switching to 3.x as their default, and non-3.x compatible code will stop working. Embracing 3.x doesn't have to mean turning your back to 2.x users. For a typical PyGame game, there are only a couple simple things you have to dance around. And it's a fairly simple dance, too:

Python 3.x compatibility Tips

  • Write legacy_map, legacy_filter, etc. that re-implements the behvior of the 2.x versions of map and filter, etc. if those are functions you even use. You could also do the same with range and create a legacy_range, but as stated earlier, you really shouldn't use [x]range.
  • Put parenthesis around all print statements.
  • Use // for integer division, and add 0.0 to ints if you intend for float division.
  • Don't throw custom exceptions. If exceptions are occuring as part of your intended codepath in a game, you are probably doing something wrong anyway.
  • Install Python 2.5, 2.6, 2.7, 3.1, and 3.2. Create execution scripts (.sh/.bat) that runs your game using these versions. Call them run26.bat, run32.bat, etc. Before you check in code, run your game using a 2.x version and 3.x version. This is also especially useful for ensuring you don't have any stray print statements you were using for debugging. If you write debug print statements without parenthesis and accidentally leave it there, the 3.x version will give a syntax error if you try to run it.