Login | Register

Nerd Paradise

Madmen wanted

PyGame Tips & Tricks

Post by: Blake
Posted on: 12 Cresco 12:0 - 9.85.23
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.
facebook twitter Stumbleupon Reddit del.icio.us Digg
User Comments: 7
aviel
Post by aviel on 12 Cresco 12:0 - 13.75.49
Wooh, new article!
Post by tartley on 12 Cresco 12:1 - 7.24.14
Love the post - thanks for writing up.

One thought. You create a cache key by converting a collection of arbitrary values into a string, using "key = '|'.join(map(str, params))" In many situations you don't have to bother, you could use 'params' directly if it is a tuple, as it will be in a '*args' function parameter.

Cheers.
blake
Post by Blake on 12 Cresco 12:2 - 8.76.85
Thanks! Article updated.
omnipotententity
Post by OmnipotentEntity on 13 Cresco 12:4 - 6.35.7
I was reading through this randomly and I saw something that didn't ring true to me, so I tested it:

>>> timeit.timeit("a = 0\nwhile a < 100: a += 1")
3.669222831726074
>>> timeit.timeit("a = 0\nwhile a < 100: a += 1")
3.5469729900360107

>>> timeit.timeit("for a in xrange(100): pass")
1.3740379810333252
>>> timeit.timeit("for a in xrange(100): pass")
1.4116270542144775


It seems that a for xrange is a little over twice as fast as a while loop.

Here's why:

>>> def while_loop():
...   a  = 0
...   while a < 100:
...     a += 1
...
>>> dis.dis(while_loop)
  2           0 LOAD_CONST               1 (0)
              3 STORE_FAST               0 (a)

  3           6 SETUP_LOOP              26 (to 35)
        >>    9 LOAD_FAST                0 (a)
             12 LOAD_CONST               2 (100)
             15 COMPARE_OP               0 (<)
             18 POP_JUMP_IF_FALSE       34

  4          21 LOAD_FAST                0 (a)
             24 LOAD_CONST               3 (1)
             27 INPLACE_ADD         
             28 STORE_FAST               0 (a)
             31 JUMP_ABSOLUTE            9
        >>   34 POP_BLOCK           
        >>   35 LOAD_CONST               0 (None)
             38 RETURN_VALUE        
>>> def for_xrange():
...   for a in xrange(100):
...     pass
...
>>> dis.dis(for_xrange)
  2           0 SETUP_LOOP              20 (to 23)
              3 LOAD_GLOBAL              0 (xrange)
              6 LOAD_CONST               1 (100)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_FAST               0 (a)

  3          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               0 (None)
             26 RETURN_VALUE


ie, less generated python code, avoided symbol lookups, less branching in python code (because xrange is implemented in C).

The for loop jumps between 13 and 19. And only performs a FOR_ITER and a STORE_FAST. Whereas the while loop jumps between 9 and 31 and performs two loads, then a compare, then another two loads, an add and a store.

EDIT:

Did some further testing. Here are the results. It seems that while the for loop is definitely better equipped for longer loops while does beat it simply due to initial setup time on very short loops. The crossover point seems to be between n = 5 and n = 6

>>> output = []
>>> for a in xrange(50):
...   out1 = timeit.timeit("for a in xrange(%d): pass" % a)
...   out2 = timeit.timeit("a = 0\nwhile a < %d: a += 1" % a)
...   output.append((out1, out2))
... 
>>> output
[(0.1693739891052246, 0.0403289794921875), (0.18043112754821777, 0.07681703567504883), 
(0.21985793113708496, 0.12548303604125977), (0.21836185455322266, 0.15902495384216309), 
(0.22150921821594238, 0.19478416442871094), (0.24390602111816406, 0.23216605186462402), 
(0.2553260326385498, 0.26576805114746094), (0.25646209716796875, 0.2921018600463867), 
(0.2745389938354492, 0.328732967376709), (0.2858760356903076, 0.364063024520874), 
(0.29178380966186523, 0.4663569927215576), (0.29974985122680664, 0.43823885917663574), 
(0.31618189811706543, 0.48936891555786133), (0.3261568546295166, 0.5504488945007324), 
(0.3413839340209961, 0.5418899059295654), (0.3457460403442383, 0.5812418460845947), 
(0.36572790145874023, 0.6630840301513672), (0.37395215034484863, 0.659600019454956), 
(0.3916289806365967, 0.6842617988586426), (0.3924880027770996, 0.7150819301605225), 
(0.4055469036102295, 0.7557189464569092), (0.4248361587524414, 0.790748119354248), 
(0.44399595260620117, 0.8251960277557373), (0.4506838321685791, 0.8505580425262451), 
(0.45045018196105957, 0.8913300037384033), (0.4647669792175293, 0.9246890544891357), 
(0.4741959571838379, 0.9660639762878418), (0.48926711082458496, 1.0161681175231934), 
(0.5144519805908203, 1.0875811576843262), (0.5358898639678955, 1.1008281707763672), 
(0.5326781272888184, 1.1148548126220703), (0.5421240329742432, 1.1650540828704834), 
(0.5599961280822754, 1.18522310256958), (0.5725359916687012, 1.2826008796691895), 
(0.5665860176086426, 1.2649900913238525), (0.599708080291748, 1.3159191608428955), 
(0.6220500469207764, 1.4549908638000488), (0.6233158111572266, 1.3689448833465576), 
(0.6378860473632812, 1.4159929752349854), (0.6525859832763672, 1.4684481620788574), 
(0.6745948791503906, 1.464148998260498), (0.6845729351043701, 1.500560998916626), 
(0.6925349235534668, 1.5453300476074219), (0.714040994644165, 1.5662457942962646), 
(0.7282979488372803, 1.7464148998260498), (0.7345561981201172, 1.8035118579864502), 
(0.7422418594360352, 1.6669979095458984), (0.7586369514465332, 1.718761920928955), 
(0.7829949855804443, 1.7476630210876465), (0.7867331504821777, 1.7759299278259277)]


EDIT2:

It seems that even the humble range performs better than the while loop after a small bit crossover seems to be between n = 10 and n = 11:

>>> output = []
>>> for a in xrange(50):
...   out1 = timeit.timeit("for a in xrange(%d): pass" % a)
...   out2 = timeit.timeit("a = 0\nwhile a < %d: a += 1" % a)
...   out3 = timeit.timeit("for a in range(%d): pass" % a)
...   output.append((out1, out2, out3))
... 
>>> output
[(0.17270207405090332, 0.040087223052978516, 0.18934297561645508), 
(0.2034599781036377, 0.08072590827941895, 0.2715950012207031), 
(0.2121598720550537, 0.1271378993988037, 0.31476306915283203), 
(0.23373007774353027, 0.1635751724243164, 0.31957006454467773), 
(0.25392699241638184, 0.19713401794433594, 0.32254695892333984), 
(0.2582230567932129, 0.22099709510803223, 0.3630249500274658), 
(0.2505049705505371, 0.2666778564453125, 0.3445470333099365), 
(0.27762579917907715, 0.29615211486816406, 0.3725900650024414), 
(0.2790350914001465, 0.32990097999572754, 0.3809530735015869), 
(0.29888296127319336, 0.3752281665802002, 0.39354515075683594), 
(0.29457807540893555, 0.39475297927856445, 0.4036078453063965), 
(0.3164248466491699, 0.44318294525146484, 0.43104100227355957), 
(0.32030296325683594, 0.4741671085357666, 0.44118690490722656), 
(0.3633298873901367, 0.5049018859863281, 0.4673330783843994), 
(0.35173487663269043, 0.5414619445800781, 0.5002470016479492), 
(0.37034106254577637, 0.5750489234924316, 0.5424020290374756), 
(0.36805295944213867, 0.6424810886383057, 0.5537989139556885), 
(0.38703083992004395, 0.6483941078186035, 0.5869429111480713), 
(0.3964390754699707, 0.6686050891876221, 0.5678451061248779), 
(0.4216470718383789, 0.7313270568847656, 0.5862588882446289), 
(0.39815306663513184, 0.74639892578125, 0.6237969398498535), 
(0.44016599655151367, 0.7827639579772949, 0.6236710548400879), 
(0.43929290771484375, 0.8166680335998535, 0.6414380073547363), 
(0.46102309226989746, 0.920219898223877, 0.6515908241271973), 
(0.4518558979034424, 1.0434999465942383, 0.6508991718292236), 
(0.4813048839569092, 0.9414491653442383, 0.6647248268127441), 
(0.4887669086456299, 0.9702548980712891, 0.6942059993743896), 
(0.49849486351013184, 1.0242118835449219, 0.7119760513305664), 
(0.5103118419647217, 1.0951080322265625, 0.7264261245727539), 
(0.5363419055938721, 1.1510021686553955, 0.746553897857666), 
(0.5560550689697266, 1.1044158935546875, 0.7712929248809814), 
(0.5603821277618408, 1.1501500606536865, 0.782567024230957), 
(0.5677480697631836, 1.184643030166626, 0.7841651439666748), 
(0.5822930335998535, 1.2411229610443115, 0.7896101474761963), 
(0.5755879878997803, 1.2619669437408447, 0.8002171516418457), 
(0.5965209007263184, 1.3041460514068604, 0.809471845626831), 
(0.6049132347106934, 1.458549976348877, 0.8944151401519775), 
(0.6448268890380859, 1.4626600742340088, 0.8628089427947998), 
(0.6293139457702637, 1.5098590850830078, 0.9214160442352295), 
(0.6592910289764404, 1.524176836013794, 0.8932850360870361), 
(0.652616024017334, 1.4751310348510742, 0.9371669292449951), 
(0.6855711936950684, 1.539543867111206, 0.9104418754577637), 
(0.6802959442138672, 1.5996358394622803, 0.9562749862670898), 
(0.7071859836578369, 1.5944969654083252, 0.9694099426269531), 
(0.7180428504943848, 1.680069923400879, 0.9979739189147949), 
(0.742408037185669, 1.6673309803009033, 1.0026299953460693), 
(0.7498970031738281, 1.6662399768829346, 1.0184218883514404), 
(0.7540099620819092, 1.6978468894958496, 1.040470838546753), 
(0.7672948837280273, 1.8108088970184326, 1.032477855682373), 
(0.7827179431915283, 1.8430938720703125, 1.0639050006866455)]
deckmaster
Post by Deckmaster on 13 Cresco 12:4 - 15.1.97
I only have one thing to say to that:

WHY THE CRAP DOES ADDING 1 TO a TAKE 4 INSTRUCTIONS?!
omnipotententity
Post by OmnipotentEntity on 13 Cresco 12:5 - 7.71.55
At the urging of eofpi and Deck, I tested this under python 3.3.1: crossover seems to be between n = 5 and n = 6.

>>> for a in range(50):
...   out1 = timeit.timeit("for a in range(%d): pass" % a)
...   out2 = timeit.timeit("a = 0\nwhile a < %d: a += 1" % a)
...   output.append((out1, out2))
... 
>>> output
[(0.15887087117880583, 0.041934565640985966), (0.21183071471750736, 0.09745855070650578), 
(0.24557388806715608, 0.13749094493687153), (0.2535335449501872, 0.18169572111219168), 
(0.317585623357445, 0.23245532112196088), (0.30639586597681046, 0.27810194715857506), 
(0.2966775558888912, 0.3200721903704107), (0.3144237818196416, 0.36230999790132046), 
(0.33615286787971854, 0.42396472627297044), (0.3362777065485716, 0.47392652789130807), 
(0.3463139832019806, 0.5054764063097537), (0.38495083386078477, 0.5497662289999425), 
(0.39347809087485075, 0.606527958996594), (0.3783885804004967, 0.6541371080093086), 
(0.38964536460116506, 0.6811029361560941), (0.40882145427167416, 0.7168578081764281), 
(0.39911976316943765, 0.7790739391930401), (0.42125000106170774, 0.8200518880039454), 
(0.4263605661690235, 0.8643538830801845), (0.4534318521618843, 0.8943654731847346), 
(0.44586993800476193, 0.9619837943464518), (0.466367962770164, 0.9806696721352637), 
(0.4798898329026997, 1.1179846283048391), (0.49936911882832646, 1.0621407418511808), 
(0.4924717270769179, 1.1390613154508173), (0.5154951037839055, 1.156337429303676), 
(0.531494646333158, 1.220990982837975), (0.5428872061893344, 1.2609446900896728), 
(0.5520413969643414, 1.3165284479036927), (0.5577642689459026, 1.3519084891304374), 
(0.559954087715596, 1.408031607978046), (0.6020234329625964, 1.3970749052241445), 
(0.585582026746124, 1.61811385396868), (0.6022013681940734, 1.5383522021584213), 
(0.6067741210572422, 1.5808755927719176), (0.6323040029965341, 1.5804719300940633), 
(0.6832173457369208, 1.6640354921109974), (0.6568921338766813, 1.6980204186402261), 
(0.6633405587635934, 1.7542011407203972), (0.6800151951611042, 1.779831191059202), 
(0.6728871739469469, 1.841574959922582), (0.6965779489837587, 1.8534689820371568), 
(0.711570780724287, 1.9326288718730211), (0.7206615721806884, 1.9289465961046517), 
(0.7201396552845836, 2.0940341288223863), (0.7330319774337113, 2.02324263099581), 
(0.7424070709384978, 2.292323485016823), (0.772036265116185, 2.3647519759833813), 
(0.7822016896679997, 2.1988371601328254), (0.774699148721993, 2.1824675472453237)]


Also for fun, here are the dis under python 3.3 (it's pretty much exactly the same):

>>> def for_range():
...   for a in range(100):
...     pass
... 
>>> def while_loop():
...   a = 0
...   while a < 100:
...     a += 1
... 
>>> dis.dis(for_range)
  2           0 SETUP_LOOP              20 (to 23) 
              3 LOAD_GLOBAL              0 (range) 
              6 LOAD_CONST               1 (100) 
              9 CALL_FUNCTION            1 (1 positional, 0 keyword pair) 
             12 GET_ITER             
        >>   13 FOR_ITER                 6 (to 22) 
             16 STORE_FAST               0 (a) 

  3          19 JUMP_ABSOLUTE           13 
        >>   22 POP_BLOCK            
        >>   23 LOAD_CONST               0 (None) 
             26 RETURN_VALUE         
>>> dis.dis(while_loop)
  2           0 LOAD_CONST               1 (0) 
              3 STORE_FAST               0 (a) 

  3           6 SETUP_LOOP              26 (to 35) 
        >>    9 LOAD_FAST                0 (a) 
             12 LOAD_CONST               2 (100) 
             15 COMPARE_OP               0 (<) 
             18 POP_JUMP_IF_FALSE       34 

  4          21 LOAD_FAST                0 (a) 
             24 LOAD_CONST               3 (1) 
             27 INPLACE_ADD          
             28 STORE_FAST               0 (a) 
             31 JUMP_ABSOLUTE            9 
        >>   34 POP_BLOCK            
        >>   35 LOAD_CONST               0 (None) 
             38 RETURN_VALUE
blake
Post by Blake on 13 Cresco 13:4 - 16.54.26
Thanks, Omni!

I've updated the article.
You must be logged in to add a comment
Current Date: 14 Cresco 6:1Current Time: 9.34.58Join us in IRC...
Server: irc.esper.net
Channel: #nerdparadise
Your IP: 54.196.194.204Browser: UnknownBrowser Version: 0