[Rod Stephens Books]
Index Books Python Examples About Rod Contact
[Mastodon] [Bluesky]
[Build Your Own Ray Tracer With Python]

[Beginning Database Design Solutions, Second Edition]

[Beginning Software Engineering, Second Edition]

[Essential Algorithms, Second Edition]

[The Modern C# Challenge]

[WPF 3d, Three-Dimensional Graphics with WPF and C#]

[The C# Helper Top 100]

[Interview Puzzles Dissected]

Title: Make a game framework with Python and Pygame

[A bouncing ball animation made with Python and Pygame]

I was hoping to start with an easy example and gradually improve it, but really it seems like there's little point starting with something basically useless. This example covers pretty much the bare minimum needed to be worth the effort. Even it isn't really a game because it's not interactive; you just watch it.

It's pretty long, but the pieces are relatively simple if you take them one at a time.

Pygame Requirements

The basic approach to building a game with Pygame is to start with some setup and initialization. The program then enters a loop where it continually updates the objects that it displays until the game should end.

You can do this in many ways, but keeping track of everything can be confusing if you don't have a plan. This example uses one common approach. It creates an application class that controls the whole thing. Its constructor gets Pygame started and then calls its event_loop method to process events until the game ends. Other class methods do whatever is necessary such as updating objects, creating new objects, and responding to user events.

The main program merely instantiates the application class and the constructor does everything else.

Usually this kind of program uses sprite classes to control objects. A sprite is an object that represents something that the program will display on the screen. This example uses the sprite class BallSprite to represent bouncing balls.

Let's start there and then move on to the main application class.

Pygame actually has a Sprite class that you can use as a parent class for your sprite classes. It doesn't seem to buy you much, though, so I build my sprites from scratch.

BallSprite

The BallSprite class represents a bouncing ball. It must do three things: initialize a ball, update a ball after some time passes, and draw the ball.

BallSprite Constructor

Here's the class's constructor, which initializes the ball.

class BallSprite: def __init__(self, the_app, center, radius, velocity, color): ''' Save the ball's parameters. center - A tuple giving the X and Y coordinates of the ball's center. velocity - A tuple giving the ball's speeds in pixels per second in the X and Y directions. ''' self.the_app = the_app self.center = center self.radius = radius self.velocity = velocity self.color = color

The constructor simply saves the ball's properties:

  • center - The X and Y coordinates of the ball's center
  • radius - The ball's radius
  • velocity - The ball's X and Y velocities in pixels per second
  • color - The ball's fill color

The code also saves a reference to the main application class object, in this example named the_app. This example only uses that value to notify the main application when the ball bounces off of the window's sides. In other applications, this value might be used to do things like test for collisions, create new sprites, or tell the application when some event has occurred like an object hits a target area.

BallSprite update Method

The following code shows the BallSprite class's update method.

def update(self, elapsed_seconds, bounds): '''Update the ball's position.''' # Calculate the min and max X value the ball's center can have. xmin = bounds.left + self.radius xmax = bounds.right - self.radius # Get the current velocity components. vx = self.velocity[0] vy = self.velocity[1] # Update the ball's X coordinate. x = self.center[0] + self.velocity[0] * elapsed_seconds if x < xmin: self.the_app.sprite_bounced() x = xmin + (xmin - x) vx = -vx elif x >= xmax: self.the_app.sprite_bounced() x = xmax - (x - xmax) vx = -vx # Calculate the min and max Y value the ball's center can have. ymin = bounds.top + self.radius ymax = bounds.bottom - self.radius # Update the ball's Y coordinate. y = self.center[1] + self.velocity[1] * elapsed_seconds if y < ymin: self.the_app.sprite_bounced() y = ymin + (ymin - y) vy = -vy elif y >= ymax: self.the_app.sprite_bounced() y = ymax - (y - ymax) vy = -vy # Update the ball's velocity and position. self.velocity = (vx, vy) self.center = (x, y) # Return True to indicate this sprite is still alive. return True

The application class calls update periodically to let the sprites update themselves. The amount of time that passes between calls to update may vary depending on such factors as the number of sprites in use, the type and number of other things running on your computer, and random system issues, so you cannot simply add constant values to the sprite's X and Y coordinates. Instead you should scale velocity, acceleration, rotation, and any other changing values by the elapsed time since the last time update executed.

In this example, that means multiplying the ball's velocity by the elapsed time to see how far the ball has moved and then adding the result to the ball's position.

Before it gets to that, however, the method calculates the minimum and maximum X values the ball can have and still remain entirely visible on the window. It then sets vx and vy to the ball's velocity components (in pixels per second).

The code then adds the ball's current X coordinate to the X velocity times the elapsed time (in seconds). If that value is less than the minimum value xmin, the ball has moved past the left edge of the window. In that case, the code calls the application class's sprite_bounced so it knows that the ball hit the side.

The code then calculates the distance that the ball moved past the window's edge (xmin - x) and adds it to the edge so ball moves back onto the window. (For example, if the ball was 5 pixels off the edge of the window, the code moves it so it is 5 pixels inside the window.)

The code also reverses the X velocity component so the ball starts moving to the right.

The method performs similar steps if the ball moves off of the window's right edge. After it's finished updating the X position and velocity, it performs similar steps for the Y position and velocity.

Finally, the method returns True to indicate that this sprite is still alive. (It's just my convention that the update method returns True to tell you the sprite is alive.) In this example, the balls never die so this update methods always return True. In other games you might want to return False if a bullet leaves the playing area, a ship explodes, a building is destroyed, etc.

BallSprite draw Method

As you can probably guess from its name, the draw method draws the ball.

def draw(self, surface): '''Draw the ball.''' pygame.draw.circle(surface, self.color, self.center, self.radius)

The method takes a surface as a parameter and uses that object's draw.circle method to draw the ball.

That's all there is to the BallSprite class. The rest of the program is in the BounceApp class.

BounceApp

The BoundeApp class is the heart of the game. It handles Pygame chores, creates the sprites, and updates the sprites over time.

BounceApp Constructor

The class's constructor does all of the work either directly or by calling other methods. Here's the constructor.

class BounceApp: def __init__(self, game_name, screen_size, max_fps, bg_color, num_sprites, min_radius, max_radius, min_velocity, max_velocity): # Save game parameters. self.game_name = game_name self.screen_size = screen_size self.max_fps = max_fps self.bg_color = bg_color # Initialize pygame. pygame.init() pygame.display.set_caption(f'{self.game_name}') self.surface = pygame.display.set_mode(self.screen_size) self.bounds = self.surface.get_rect() # Prepare sounds. self.boing_sound = pygame.mixer.Sound('boing.wav') # Make initial sprites. self.make_sprites(num_sprites, min_radius, max_radius, min_velocity, max_velocity) # Start the event loop. self.event_loop()

The constructor takes a bunch of parameters that it uses to initialize the window and the game. It saves those values just in case it needs them again later (it doesn't in this example). It then initializes Pygame. The object's surface field holds the main Pygame surface where game objects will appear. It's important for sprites so they can draw themselves on it.

Next the code loads the sound file boing.wav so the program can play the sound when a ball bounces off of the window's sides.

The program then calls make_sprites (described shortly) to create some random sprites. In a different game, this is where you would create whatever sprites the game needs.

Finally, the constructor calls the event_loop method which runs until the game ends.

sprite_bounced

When a sprite bounces off of the game's window edges, it calls the BounceApp class's sprite_bounced method shown in the following code.

def sprite_bounced(self): '''Play the sprite bounced sound.''' self.boing_sound.play()

This method simply plays the boing sound effect.

make_sprites

The following code shows the make_sprites method.

def make_sprites(self, num_sprites, min_radius, max_radius, min_velocity, max_velocity): '''Make the sprites.''' self.all_sprites = [] for _ in range(num_sprites): radius = random.randrange(min_radius, max_radius) min_x = self.bounds.left + radius max_x = self.bounds.right - radius x = random.randint(min_x, max_x) min_y = self.bounds.top + radius max_y = self.bounds.bottom - radius y = random.randint(min_y, max_y) center = (x, y) vx = random.randint(min_velocity, max_velocity) if random.randint(0, 1) == 0: vx = -vx vy = random.randint(min_velocity, max_velocity) if random.randint(0, 1) == 0: vy = -vy velocity = (vx, vy) self.all_sprites.append( BallSprite(self, center, radius, velocity, random_color()))

This method takes parameters that give bounds for the sprite properties such as velocity and size.

The code creates an all_sprites list and then loops through the desired number of sprites. For each sprite, it picks random values for the sprite's radius, X and Y position, and X and Y velocity. It uses if random.randint(0, 1) == 0 tests so there is a chance that the ball will move in the negative X or Y direction.

After it has picked all of the random values, the code creates a BallSprite and adds it to the all_sprites list.

This game stores all of its sprites in a single list. A more complicated game might have other lists for different kinds of sprites like the player, enemies, stationary objects, bullets, turrets, etc. It's still handy to have one all_sprites list that holds every sprite to make it easier to update every sprite all at once.

event_loop

The event_loop method is where the program simulates all of the game's action.

# Process events. def event_loop(self): clock = pygame.time.Clock() running = True while running: # Event loop. for event in pygame.event.get(): if event.type == pygame.locals.QUIT: running = False break # See how much time has passed since the last update. elapsed_ticks = clock.tick(self.max_fps) elapsed_seconds = elapsed_ticks / 1000 # Update the sprites. self.update_sprites(elapsed_seconds) # Draw the sprites. self.draw_sprites() # Update the display. pygame.display.update() # End the program. pygame.display.quit() pygame.quit()

This method first gets a Pygame clock object to keep track of time. It then sets running = True and enters a while loop that runs as long as running is True. All games have some sort of similar loop and it's commonly called the "game loop" or the "event loop."

Inside the game loop, the code loops through the Pygame events produced by pygame.event.get. This game only looks for one event type, pygame.locals.QUIT, which indicates the user is trying to close the application. If it sees that event, the code sets running = False to end the game loop.

If the user isn't trying to end the program, the code calls clock.tick. That method takes a frame rate as a parameter that indicates the maximum number of frames you want to run per second. That value lets you limit the speed of the program so it doesn't totally hog the CPU. The clock.tick method delays the program if necessary to limit its speed and then returns the number of ticks (milliseconds) that have elapsed since the last time it was called.

It's important to remember that the frame rate is approximate and depends on things like the system's load, the number of sprites you have running, how long the sprites tale to do their update calculations, and so forth.

The code divides the elapsed number of ticks to get elapsed seconds and then calls update_sprites (shown shortly) to let the sprites update themselves. After that, it then calls draw_sprites to let the sprites draw themselves.

In many games, it's important to use separate update_sprites and draw_sprites methods rather than putting all of that code in a single method. That lets all of the sprites update themselves based on their current positions and not mess with moving sprites while they are updating.

For example, suppose you have a space game where bullets and spaceships are flying around. Ideally you could perform integration over the elapsed time to see if the path of a bullet intersects the path of a ship. Unfortunately that would be complicated and slow. Things would also be confusing if the objects are moving as you try to see if any have collided. It's much easier and reasonably accurate to simply see if there are any intersections (in update_sprites) and then move the surviving sprites (in draw_sprites).

For another example, suppose you have a gravity simulator where planets are orbiting around each other. Each one exerts a force on the others. The calculations are more reliable if you calculate all of the forces before you start moving planets around.

Anyway, after calling update_sprites and draw_sprites, the event_loop method calls pygame.display.update to display the Pygame surface. When the sprites draw themselves, Pygame doesn't immediately display the result. Instead it builds the image in memory and only displays it when you call update.

Pygame actually provides two methods for displaying the surface: pygame.display.update and pygame.display.flip. The flip method updates the entire surface. If you pass rectangles into update, it only updates those rectangles and that can be faster than updating the whole surface. If you don't pass any rectangles into update, as this example does, the result is the same as using flip. I used update so it would be slightly easier to pass in rectangles if you modify the program and decide to do it that way

Finally, after the while loop ends (because the user is trying to stop the program), the code calls pygame.display.quit to close the display and then pygame.quit to close Pygame. The event_loop method then ends, the constructor that called it ends, and the program stops.

The rest of the program is fairly straightforward.

update_sprites

The following code shows the update_sprites method.

# Update the sprites. def update_sprites(self, elapsed_seconds): '''Let the sprites update themselves.''' living_sprites = [] for sprite in self.all_sprites: # The sprite.update function returns True if the sprite is still alive. if sprite.update(elapsed_seconds, self.bounds): # This sprite is still alive. living_sprites.append(sprite) # Save the living sprites. self.all_sprites = living_sprites

This method creates a living_sprites list to keep track of the sprites that are still alive after they update themselves. It then loops through the sprites and calls their update methods. If that method returns True, the sprite is still alive and the code adds it to the living_sprites list.

After all of the sprites have been updated, the method replaces the all_sprites list with the living_sprites. Any sprites that aren't on the list are discarded. For example, if a missile hits a spaceship, that sprite's update method returns False and it's not added to the living_sprites list.

draw_sprites

The draw_sprites method is even simpler than update_sprites.

# Draw the sprites. def draw_sprites(self): self.surface.fill(self.bg_color) for sprite in self.all_sprites: sprite.draw(self.surface)

This code fills the Pygame surface with the program's background color. It then loops through the sprites and calls their draw methods to make them draw themselves on the surface.

Main Program

The following code shows the example's main program.

# Main program. app = BounceApp('Bouncing Balls', screen_size=(800, 600), max_fps=30, bg_color=pygame.Color('cadet blue 3'), num_sprites=10, min_radius=20, max_radius=50, min_velocity=30, max_velocity=200) print('Done')

This code simply instantiates the BounceApp class. That class's constructor initializes Pygame, creates sprites, and runs its event loop until the game ends.

After the game ends, the program prints "Done" so you know it's over.

Summary

As I said, this is kind of long but the parts are relatively simple. The result is a fairly powerful framework that you can modify to build more interesting games. Just create more sprite classes and create sprites in the app class's make_sprites method. You may also want to let sprites create other sprites in their update methods. For example, a spaceship's update method might launch a missile by creating a new sprite. I'll post some enhanced versions of this program later to do things like that and to respond to user events.

Meanwhile, download the example to experiment with it and to see additional details.

© 2024 Rocky Mountain Computer Consulting, Inc. All rights reserved.