Tower Defense Game: Game Loop and Initializing PyGame

How do you instruct your computer game to draw things to the screen consistently and to a drum-beat that ensures there’s no awkward pauses or jitter? What about listening to the keyboard and mouse inputs, or updating the score board for your game?

Get any one of these things wrong, or forget to do them, and your game misbehaves. Worse, it can misbehave in ways that you won’t necessarily catch on your own machine.

That’s why all computer games – big or small – has one (or more!) game loops that ensure the game carries out its most essential tasks in a repeatable and stable manner.

It’s time to write our skeleton game loop that will serve us throughout the course of the development of our tower defense game.

PyGame

If you have never used Pygame before, there’s a few things you should know about it. Pygame is what I’d term a “low-level” library. Its main aim is to provide a canvas – literally – that you can draw on, with a wide range of useful game development primitives and helpers to get you building.

Out of the box, you get:

  1. Simple Sprite management

  2. Primitives for simple collision detection

  3. Basic sound mixing

  4. Software or Hardware-accelerated 2d canvas drawing thanks to the SDL library

  5. Vector and Affine Transformation (like rotation and scaling)

  6. Keyboard/Mouse and event handling

  7. A number of drawing primitives, like circles, lines, rectangles, etc.

By the way …

Sprite is an old-fashioned term for a 2d image drawn to your screen. Games consoles in the 80s and 90s typically came with hardware optimized for sprite drawing and handling.

That, more or less, is it. At the outset that may not seem like a boon, but for a Python developer it absolutely is!

You’ll learn far more building your games with these primitives and, as you’ll see, you can do a lot of cool stuff with these primitives once understand the basic concepts.

Initializing PyGame

Initializing PyGame is going to follow the same initialization of all other aspects of our game, which should look a bit like this:

  1. The game is started

  2. PyGame is set up and initialized

  3. Assets we need are loaded

  4. The game proceeds to its next task, whatever that might be, like showing a menu or an intro

The important thing here, though, is that we want to do points 2 and 3 once. PyGame has to do a lot of housekeeping behind the scenes when it’s initialized, and it absolutely does not want to be initialized more than once — expect crashes and errors if you do!

We should also ensure we only load assets once; as you’ll see, we want to keep a high frame rate and loading assets is anything but fast.

That, in fact, goes for lots of other things: if we only have to do it once, we should strive not to do it more than once. Sounds obvious, but it’s easy to miss things, and getting it wrong can take its toll on the game’s frame rate.

The Frame Rate – measured in frames per second – is the rate with which we update the screen. 60 FPS is the aim in this tutorial, but not all games require that high (or even higher still) a frame rate.

Let’s start out by importing PyGame:

>>> import pygame

Now to initialize PyGame.

def init(screen_rect: pygame.Rect, fullscreen: bool = False):
    pygame.init()
    window_style = pygame.FULLSCREEN if fullscreen else 0
    # We want 32 bits of color depth
    bit_depth = pygame.display.mode_ok(screen_rect.size, window_style, 32)
    screen = pygame.display.set_mode(screen_rect.size, window_style, bit_depth)
    pygame.mixer.pre_init(
        frequency=44100,
        size=32,
        # N.B.: 2 here means stereo, not the number of channels to use
        # in the mixer
        channels=2,
        buffer=512,
    )
    pygame.font.init()
    return screen

This function accomplishes quite a bit, so let’s go over each part:

  1. Every game occupies a certain screen size — that is what screen_rect is for. As you’ll see in a bit, I will define this later on to be a multiple of the size of our graphics tiles.

  2. We can run the game in fullscreen mode if we specify that function parameter

  3. We insist on 32-bit color depth. That leaves us with 8 bits for each of the three color channels computers use: red, green, and blue. And another 8-bit channel for the alpha channel, which governs the translucency of the pixel when it’s drawn to the screen.

    The color depth is important. We make use of semi-translucent pixels frequently. So even if you’re interested in low-bit-depth pixel art, you may still want 32 bits for no other reason than to make it possible to do seamless alpha blending.

  4. We also initialize the sound mixer with sensible defaults.

  5. And finally, we initialize the font rendering engine, so we can draw text to the screen later.

Now, at this point, you can run this init function with a suitable rectangle size:

>>> init(pygame.Rect(0, 0, 1024, 768))
<Surface(1024x768x32 SW)>

Done right, a window with a black canvas should appear. But as you’ll quickly realize, it doesn’t respond to events; not from your keyboard or mouse, nor from your operating system! Your OS will quickly report that it appears it’s hanging.

That’s because we are not responding to events from the operating system. The OS does all the heavy lifting here: it draws stuff, it reads from your peripherals and transmits them to the window and the underlying event loop that would ordinarily transact those event messages.

So, then… how do we process these events?

The Game Loop

Reduced to its simplest parts, a game loop is nothing more than an infinite loop with stuff in it:

while True:
    do_something()
    do_something_else()

But of course, it’s never really that simple. For starters, you need to separate things that you only ever want to run once – like initializing PyGame – and things you definitely want to do more than once, like drawing stuff to the screen, or reading events from the mouse or keyboard.

Indeed, our game loop exists to centralize activities we must do every frame. Some of those things are:

  1. Drawing stuff to the screen

  2. Telling all our sprites to update themselves: enemies keep traversing the path they are walking; bullets keep flying.

  3. Checking if there are any collisions we should deal with: a turret catching sight of an enemy; or a bullet impacting an enemy.

  4. Handling OS and keyboard/mouse events

  5. … and things like checking if we’ve won or lost the game; whether to spawn more enemies; etc.

And, finally, to instruct PyGame to wait until the next cycle is due to run.

Game Ticks, Frames, and Looping

A game tick is one full iteration of the game loop you saw described above.

If our goal is 60 frames per second, then we can calculate the most time we can spend on each game tick (or the time we can spend preparing a single frame):

frame length = 1 second / desired frame rate

That’s around ~16 milliseconds, maximum, of time we have to do all of the above as I explained our game loop. If you take too long, your frame rate sinks and it goes below its intended target; worse, if your game loop takes a highly variable amount of time to run, and you’ll end up with lag and jerky controls.

However, a too high frame rate would unnecessarily complicate our game also. It’s generally easier to tie things like physics (the speed with which bullets travel, or enemies move, in our case) to the frame rate of the game.

That way we can make general statements about the rate of change of something as a multiple of game ticks. For instance, if a bullet has to traverse the game map, covering 1000 pixels, how long should it take it to move one pixel? That is, above all else, a game design decision and not a technical one.

If we want it to move from one end of the map to another in 2 seconds, then it should move:

pixels per tick = 1000 pixels / (60 fps * 2 seconds)

Which is around ~8.3 pixels per tick.

It’s a simple, and effective, method of measuring the rate of change per tick. This approach is works well here because you can intuitively reason about it with simple fractions: 30 ticks is half a second; 60 is one second; and 120 is two.

So, we need to keep track of our desired frame rate.

DESIRED_FPS = 60

I will use the familiar notation in Python of uppercasing words that’re constants and defined, globally, at the module-level. In the demo you’ll find them in constants.py.

Ordinarily, you’d use a timer to calculate how long your game loop took to execute and then wait the difference between the desired frame length and how long it actually took, to maintain a stable frame rate.

In PyGame, we don’t have to do that, as PyGame can do it for us:

clock = pygame.time.Clock()
while True:
    # ... do game loop stuff ...
    pygame.display.set_caption(f"FPS {round(clock.get_fps())}")
    clock.tick(DESIRED_FPS)

PyGame will figure out how long it has to wait to maintain your frame rate. The frame rate will vary slightly; it’ll nudge up or down a few frames, but a lot of that is no doubt attributable to Python’s garbage collector.

Designing the Game Loop

I talk about a game loop, singular, as though a game should only have one. But you can easily have more than one; we’ll talk about why in a little bit when we discuss finite state automata. The main benefit of multiple game loops is that we can have one for every major, distinct, portion of our game: the main menu, the game play, the win/lose score screen, and map editing.

Why? Well, we want to separate our concerns, meaning stuff that has nothing to do with one another: the main menu and the win/lose score screen, for instance, shouldn’t know about each other at all. Mixing up their states – enemies and turrets on screen, the map, etc. – are all very different. You could merge them into one big, nasty game loop, but you’d wind up with an awful lot of if statements!

Better to think about a simple design that avoids this chaos. In the demo, as I will here, I will use classes and basic OOP principles to separate our concerns.

Needless to say, the general idea behind designing the game loop is to make it pliable enough to serve different use cases, without gold plating it too much with unnecessary abstractions.

So what we want is a class to serve as the main entry point for our game that can then hand off control to a game loop:

  1. It must have something to initialize PyGame and any other one-off actions.

  2. It should encapsulate the outcome of these initialized actions, like the screen object we get back from calling init().

  3. It should know how to pass off work to other parts of the game when events take place: the player wants to edit a map; the player loses or wins a game; etc.

Here’s a rough cut template to get us started. We’ll add things as we go along.

from dataclasses import dataclass


@dataclass
class TowerGame:

    screen: pygame.Surface
    screen_rect: pygame.Rect
    fullscreen: bool

    @classmethod
    def create(cls, fullscreen=False):
        game = cls(
            screen=None,
            screen_rect=SCREENRECT,
            fullscreen=fullscreen,
        )
        game.init()
        return game

    def loop(self):
        pass

    def quit(self):
        pygame.quit()

    def start_game(self):
        self.loop()

    def init(self):
        pygame.init()
        window_style = pygame.FULLSCREEN if self.fullscreen else 0
        # We want 32 bits of color depth
        bit_depth = pygame.display.mode_ok(self.screen_rect.size, window_style, 32)
        screen = pygame.display.set_mode(self.screen_rect.size, window_style, bit_depth)
        pygame.mixer.pre_init(
            frequency=44100,
            size=32,
            # N.B.: 2 here means stereo, not the number of channels to
            # use in the mixer
            channels=2,
            buffer=512,
        )
        pygame.font.init()
        self.screen = screen

We’ll need to introduce a new constant, SCREENRECT, which represents the width and height of the canvas and thus the screen surface we’ll get back from PyGame when we initialize it:

# Replace width and height with the desired size of the game window.
SCREENRECT = pygame.Rect(0, 0, width, height)

The loop method is blank for now. We’ll deal with it later. The init method you know now: it’s the one from before, but amended slighty to make better use of classes.

Of note is the method create, a class method, and the use of @dataclass. Both are useful patterns, so let’s talk about them now as they’ll feature regularly going forward.

What are dataclasses?

One of the annoying features of class writing in Python is having to manually assign attributes you pass into the constructor:

class SomeClass:

    def __init__(self, a, b, ...):
        self.a = a
        # ... etc ...

Another is generating a __repr__ method that prints a nice representation of the object’s internal state, something that is mostly useful for debugging and interactive development in the Python shell.

Those are just two of many of the features that the dataclasses module aims to solve. It can do an awful lot more than that; but those are two of the primary benefits to us, right now.

Instead of creating an explicit constructor, you express your class’s requirements using Python’s type annotations after applying the @dataclass decorator to it:

@dataclass
class MyClass:

    a: int
    b: str

You can define the constructor parameters, as you can see, with simple type hinting on the class itself. There is also the fields method for things that you cannot easily capture with type hints: like automatically creating instances of objects and assigning them to attributes when an object is instantiated.

Although I use type hinting in the demo, you technically don’t have to. You can use typing.Any as a catch-all to indicate you don’t mind what the type is.

We’ll make heavy use of dataclasses in this course: it’s a major time saver, and it lets us focus on more important things than basic house keeping. However, as you’ll see, some of PyGame’s builtin classes do not use dataclasses, and in those cases, we’ll have to revert to the ‘classic’ method of creating and assigning values in constructors.

Because you annotate your classes with the attributes its constructor should take, you really don’t need an __init__ constructor at all. In fact, you must use __post_init__ instead. But as you’ll see below, when you have to do certain actions during object instantiation, there’s usually a cleverer way of achieving it.

As it’s a broad topic deserving of a course of its own, I will refer you to the dataclass documentation for more information.

The create factory class method

One thing I avoid doing in Python (or in other languages for that matter) and that’s complex __init__ constructors. It’s all too easy to create a constructor in Python that ends up doing too much. Because a constructor is invoked, always, when a class is instantiated you can never really tell a Python class, when you instantiate this object, to please don’t do some of the stuff in the constructor you were going to do — not without feature flags to the constructor itself, or some other form of work-around, like inheriting from it and hoping you can work around it that way.

Consider this for instance:

class Foo:
    def init(self):
        # ... as before ...

    def __init__(self, screen, screen_rect):
        self.screen = screen
        self.screen_rect = screen_rect
        self.init()

This is a very common pattern. People take a number of arguments – no problem there – and store them against the object (that’s perfectly fine also) but then they go and do one or more complex operations “one-off” operations that alter state inside or outside the object. In our case, a bunch of stuff that can only be done once, like initializing PyGame, by calling self.init().

But, what if I don’t want that? Maybe I need two objects, but only one of them initialized. Maybe I pass in a screen that’s already initialized and thus I don’t need to initialize twice? The code above has no affordances for that use case. That problem rears it head in tests because you often want to interrogate and test parts of a class.

Sure, you can create a switch do_not_initialize or some such, but what if you have four different mutable actions? Do you have four switches? And who tests all the combinations work?

Back to the create method. It’s got a @classmethod so it takes the class (TowerGame as the cls argument) and not the instance as __init__ would. That means we can call TowerGame.create(...) and have it return an object, just like an ordinary instantiation call but with one added benefit:

We can control how the object is initialized and with what parameters, if any. Here I call init on the instance of TowerGame immediately after instantiating it, thus ensuring it’s all set up.

    @classmethod
    def create(cls, fullscreen=False):
        game = cls(
            screen=None,
            screen_rect=SCREENRECT,
            fullscreen=fullscreen,
        )
        game.init()
        return game

But I can also do TowerGame(screen=existing_screen , ...) if I wanted, and I don’t have to worry about re-initializing something that already is, because the constructor does not initialize automatically.

The create factory pattern is almost always better than a top-heavy constructor

You can have as many of these class methods as you like, and you don’t have to name them create, either.

I like having one when I know the object might be used in multiple ways, or if I feel the class is tedious to create manually, or if I want to set a number of “sane defaults” that I’d want to set most of the time I instantiate the object. It’s a way of capturing all those little things you do to an object after you instantiate it inside a class method instead of somewhere else in your code.

You can check out the demo for lots of examples of this pattern in action.

You get to have your cake and eat it, too

Your normal constructor is unaffected; if anything, it’s lightened of the burden of possibly doing more than it should. It’s a valid, and legitimate thing indeed, to ensure that an object’s internal state is correct before a programmer makes use of it.

Using a separate constructor class method to do this and leaving the default __init__ to do little more than the utmost basics ensures you can do both.

With dataclasses you don’t need the __init__ constructor at all

As you can see, these two approaches work well with one another: dataclasses handle the constructor stuff for you; and class methods implement the trickier parts of complex object initialization and defaults.

Running the Game Loop

We’ll need a way of running the game loop when we start the game.

def start_game():
    game = TowerGame.create()
    game.loop()

if __name__ == "__main__":
    start_game()

Create a new file – in the demo it’s called main.py – and put something akin to that in it. When you run python -m tower.main, it should invoke start_game and call our (empty) loop.

Next Steps

We need to design the actual game loop, and because our game has several distinct screens (a menu, game editor, the actual game play, and a score screen) we need a way to think about the design of this game. That’s where state machines come into play.

Summary

Dataclasses automate a lot of tedium

They are almost always a straight upgrade from ordinary classes — and let’s be honest, most classes we write are ordinary. This course will only scratch the surface of what you can do with dataclasses, so I recommend you take their use to heart, and experiment with them in other projects you work on.

The class method factory pattern captures common requirements

Many times, you will create an object, and then immediately carry out a number of activities against that object: assign this; and call that. If it’s a one-off, no problem. But often, it’s a general pattern, and something you need to do frequently, or at least indicate that there’s a common set of standards your class and application expects.

You can use the classmethod pattern to help you manage that.

Liked the Article?

Why not follow us …

Be Inspired Get Python tips sent to your inbox

We'll tell you about the latest courses and articles.

Absolutely no spam. We promise!