Learn to create your own games by dissecting a simple homebrew game.
With a little knowledge, some time, and the right tools, game programming is within your reach. As [Hack #91] and [Hack #92] demonstrated, Python and PyGame are two excellent tools for creating interactive animations. They’re also good for the rest of game programming.
Let’s explore a simple game that has all of the essential features of any 2D arcade game: animation, collision detection, user input, and a winnable challenge. Best yet, it’s a couple of hundred lines of code that you can enhance, change, polish, and adapt to create your own masterpieces.
In Bouncy Robot Escape, you control a robot trying to escape from the laboratory into the wild world of freedom. Several colorful, giant, bouncy balls (a tribute to The Prisoner) block your path. You can block their attack with a small force field. Can you find the door in time?
The game starts by loading several other Python modules:
#!/usr/bin/python import math import random import sys import time import pygame from pygame.locals import *
The math, random,
sys, and time modules provide
math, random number, operating system, and time-related functions,
respectively. You’ll encounter them all later. The
pygame lines should look familiar; the second
imports some variables used for input handling.
The main entry point of the game is the main
function. It’s very similar to that in
[Hack #92]
.
def main( ):
pygame.init( )
pygame.display.set_caption( 'Bouncy Robot Escape' )
max_x = 640
max_y = 480
screen = pygame.display.set_mode(( max_x, max_y ))
background = pygame.Surface( screen.get_size( ) ).convert( )
player = Player( 'robot.png', max_x, max_y )
door = Door( 'door.png', max_x, max_y )
balls = [ ]
ball_images = [ 'ball_blue.png', 'ball_red.png', 'ball_yellow.png' ]
rand_ball = random.Random( )
for i in range( random.randint( 3, 7 ) ):
ball_image = ball_images[ rand_ball.\
randint( 0, len(ball_images) -1 ) ]
balls.append( Ball( ball_image, max_x, max_y ) )
loop( screen, background, player, balls, door )The first two lines initialize the screen (the surface to which the game will draw everything) and add a window title showing the name of the game. The next several lines declare some variables and create some objects.
player and door are Python
objects, initialized with a graphic and the max_x
and max_y coordinates. The same goes for the balls
that chase the player, though that code is more complex.
Finally, the code starts the main loop, calling loop() with the important variables so far: the two drawable
surfaces, the player, the list of balls, and the door.
There are three different ball images: blue, red, and yellow. There
will always be three to seven balls chasing the robot. This code
creates an empty list, balls, to hold all the
balls and a list, rand_ball, of available ball
colors. Within the loop, it selects a random image from the list of
colors, creates a new Ball object, and appends it
to the balls list. You’ll use
this list later (without having to know how many balls it contains).
Beware reusing the same random number generator for multiple
purposes. I had terrible results until I created a new
Random object to initialize the ball color.
It’s difficult to generate truly random numbers, so
the algorithm passes a seed to a fixed mathematical function.
Different Random objects use different seeds, thus
generating different results.
All games have some sort of main loop. This loop handles user input, updates player and enemy positions, checks for victory and loss conditions, and draws the following screen.
def loop( screen, background, player, balls, door ):
run = 1
player_turn = 1
start_time = time.time( )This snippet fetches the variables passed from main() and sets up a few other variables. The
run flag indicates whether to continue running the
main loop. player_turn is another flag that
indicates whether the player should move in this loop iteration.
Right now, the robot moves at half the speed of the balls, so he can
move only on every other turn. Finally, start_time
is the current time. The robot has to scan the room for 10 seconds
before he can find the door. This variable keeps track of the elapsed
time:
while run:
for event in pygame.event.get( ):
if event.type = = QUIT:
return
if event.type = = KEYDOWN:
run = handle_key( event.key, player )This snippet of code checks for game-ending QUIT
events as well as keypresses. If the user closes the window, PyGame
detects a QUIT event. This function returns to
main(), in that case. If the user has pressed a
key, call the handle_key() function with the
value of the key pressed and the Player object, so
that function can make the player appropriately. The value returned
from the function ends up in run, so that the
player can press a key to quit the game by ending the loop.
draw_background( background, screen )
if door.visible:
door.draw( background )
elif time.time( ) - start_time > 1.0:
door.visible = 1
for ball in balls:
ball.move( )
ball.draw( background )
if player_turn = = 1:
player.move( )
player_turn = 0
else:
player_turn = 1
player.draw( background )This snippet starts by drawing the background onto the screen. You’ll see this function shortly. Next, it checks to see if the door is visible. If so, it draws it. If not, it checks if 10 seconds have elapsed since starting the level. At that point, the robot should see the door, so it sets the door’s visibility to true.
The code next loops through the balls list, moving
and drawing each ball. Notice that this code does not need to know
how many balls there are in the list; it will move and draw each of
them. Note that you draw to the background, so you can update the
main screen in one move.
The next code is a bit tricky. It makes sure that the player can move
every other turn. If the player_turn flag is true,
the player moves and the flag flips to false. Otherwise, the flag
flips to true. Either way, you have to draw the player on every turn
because you’re redrawing the entire screen. If you
drew the robot only when the player moved, the robot would flicker;
he’d be visible only every other turn.
That takes care of moving everything. Now let’s check for end conditions and draw everything to the main screen:
handle_collisions( screen, player, balls, door ) screen.blit( background, ( 0, 0 )) pygame.display.flip( )
By the way, draw_background() is very simple. It
fills the background with the color white and draws it on the main
screen:
def draw_background( background, surface ):
background.fill(( 255, 255, 255 ))
surface.blit( background, background.get_rect( ) )
handle_key() translates user keypresses into
actions for the robot. It’s deceptively simple:
def handle_key( key, player ):
key_events = {
K_UP: player.move_up,
K_DOWN: player.move_down,
K_LEFT: player.move_left,
K_RIGHT: player.move_right,
K_SPACE: player.enable_shield
}
if key_events.has_key( key ):
key_events[ key ]( )
elif key = = K_ESCAPE:
return 0
return 1This function calls the robot’s appropriate
move_
direction method for
every cursor keypress and enables the robot’s shield
if the user presses the spacebar. As in
[Hack #92]
, the responsibility for
moving lies with the robot. If the user presses the Escape key, this
function returns a false value, causing the main loop to exit and
quitting the program; any unhandled keypress continues the game.
One of the trickiest parts of game programming is collision detection, figuring out if any game objects have collided and deciding what to do about it. We’ll use the bounding sphere method, where every object has an invisible circle around it (it’s a 2D game, so there’s not really a sphere). If two circles overlap, the objects have collided. This is fairly easy to program. Every object has a center point and knows the radius of its bounding sphere. If the object is less than its radius in distance from another object, it has collided with the other object.
Here’s the code:
def handle_collisions( screen, player, balls, door ):
for ball_count in range( len(balls) ):
ball = balls[ball_count]
if ball.collision( player ):
if player.shield:
ball.bounce( )
else:
game_over( screen )The first snippet fetches the necessary variables. It then loops through all balls in the ball list. It first checks that the ball has collided with the player. If the player has the shield enabled, the ball bounces harmlessly away. Otherwise, the ball hits the robot, and the game ends.
for other_ball in balls[ ball_count: ]:
if ball.collision( other_ball ):
ball.bounce( )
other_ball.bounce( )Of course, the ball might collide with another ball. This code loops
over the remaining balls in the list with a list slice (from the
element with the index found in ball_count through
the end of the list), so as not to repeat any calculations already
processed; checks for collisions; and bounces each ball away from the
collision point if necessary:
if door.visible and ball.collision( door ):
ball.bounce( )Balls can also collide with the door, but only if it’s visible.[21] Again, the ball bounces away on this collision:
if door.visible and door.collision( player ):
win_game( screen )Finally, if the door is visible, and the player collides with it, the player has won.
How do the game objects know they’ve collided? Good question.
The player, the balls, and the door are all game objects, represented as Python objects. Each object has its own data (or attributes) and can perform certain behaviors (known as methods). Each object belongs to a class that describes its data and behavior.
Because Player, Ball, and
Door share common attributes (such as a radius)
and behavior (including the ability to check for collisions and the
ability to draw itself to the screen), let’s start
by defining a general class, GameObject. The other
three classes are more specific versions of
GameObject.
class GameObject:
def place_random( self ):
self.rect.x = random.randint( 0, self.max_x )
self.rect.y = random.randint( 0, self.max_y )
def draw( self, surface ):
surface.blit( self.image, self.rect )This snippet starts the class definition and defines two important
methods. place_random() sets the
object’s location to a random position;
draw() draws the image to the given surface. Both
methods assume the object has several attributes, namely
rect, max_x,
max_y, and image.
You’ll see those defined soon.
def calc_radius( self ):
half_width = self.rect.width / 2
half_height = self.rect.height / 2
return int( math.hypot( half_height, half_width ) )
def center_point( self ):
x_offset = int( self.rect.width / 2 )
y_offset = int( self.rect.height / 2 )
return [ x_offset + self.rect.x, y_offset + self.rect.y ]These two methods calculate the information needed for the
object’s bounding sphere. calc_radius() uses the object’s width and height to
draw a triangle, then uses the Pythagorean theorem to discover its
hypotenuse, also the radius of the bounding circle.
center_point() figures out the center point of
the object, returning a two-element list: the X1 and Y coordinates of
the midpoint.
Every object measures its X and Y coordinates relative to the one true coordinate system, the screen. The object’s center point is half its image’s width in pixels right and half its image’s height in pixels down from its position in the screen. It’s vitally important to use the same origin when comparing object distances.
def collision( self, other ):
my_center = self.center_point( )
other_center = other.center_point( )
delta_x = my_center[0] - other_center[0]
delta_y = my_center[1] - other_center[1]
distance = int( math.hypot( delta_x, delta_y ) )
if distance < self.radius or distance < other.radius:
return distance
return 0Finally, collision() finds the distance between
this and another game object by drawing a right triangle parallel to
the X and Y axes, through their center points, and calculating the
length of the hypotenuse. The objects have collided if this distance
is less than either of their radii.
The first place the Player class differs from
GameObject is in its constructor. It has several
attributes: an image, a shield image, its rectangle, its maximum X
and Y coordinates, its current move direction, a flag to mark whether
its shield is up, X and Y velocities, and its radius.
As before, the velocity is the value with which a coordinate will change in one turn. That is, a ball with an X velocity of -1 is moving left; a velocity of 1 means that it is moving right. 0 indicates that the ball is stationary along the X axis.
The constructor also places the player randomly in the arena by
calling place_random():
class Player( GameObject ):
def _ _init_ _( self, image, max_x, max_y ):
self.image = pygame.image.load( image ).convert_alpha( )
self.shield_img = pygame.image.load( 'force_field.png' ).\
convert_alpha( )
self.rect = self.image.get_rect( )
self.max_x = max_x - self.rect.width
self.max_y = max_y - self.rect.height
self.x_vel = 0
self.y_vel = 0
self.shield = 1
self.radius = self.calc_radius( )
self.place_random( )To make things easier, the enable_shield() method
stops the robot from moving by clearing its current X and Y
velocities, then toggles the shield flag. This is
a good place to put logic that depletes energy or to make special
kinds of robots that can move with the shield enabled. This
robot’s code is pretty simple, though:
def enable_shield( self ):
self.x_vel = 0
self.y_vel = 0
self.shield = 1Moving the robot is only a little more complicated than the example in [Hack #92] , only because moving disables the robot’s shield:
def move( self ):
if self.x_vel or self.y_vel:
self.shield = 0
if ( self.x_vel and 0 < self.rect.x < self.max_x ):
self.rect.x += self.x_vel
if ( self.y_vel and 0 < self.rect.y < self.max_y ):
self.rect.y += self.y_vel
def move_up( self ):
self.y_vel = -1
def move_down( self ):
self.y_vel = +1
def move_left( self ):
self.x_vel = -1
def move_right( self ):
self.x_vel = +1Finally, the draw() method blits the image to the
screen. It must also draw the shield image over the top of the robot,
if it’s enabled:
def draw( self, surface ):
surface.blit( self.image, self.rect )
if self.shield:
surface.blit( self.shield_img, self.rect )Like Player, Ball also has an
image, a rectangle, maximum X and Y coordinates, a radius, and X and
Y velocities, randomly chosen as -1, 0, or 1. The constructor is very
similar to that of Player:
class Ball( GameObject ):
def _ _init_ _( self, image, max_x, max_y ):
self.image = pygame.image.load( image ).convert_alpha( )
self.rect = self.image.get_rect( )
self.max_x = max_x - self.rect.width
self.max_y = max_y - self.rect.height
self.x_vel = random.randint( -1, 1 )
self.y_vel = random.randint( -1, 1 )
self.radius = self.calc_radius( )
self.place_random( )The move() method is more complicated. It checks
that the object remains in bounds and bounces the ball off any wall
that it encounters. With the simple velocity scheme here, this is as
easy as reversing the direction of the velocity:
def move( self ):
x = self.rect.x + self.x_vel
if x < 0:
x = 0
self.x_vel = -self.x_vel
elif x > self.max_x:
x = self.max_x
self.x_vel = -self.x_vel
y = self.rect.y + self.y_vel
if y < 0:
y = 0
self.y_vel = -self.y_vel
elif y > self.max_y:
y = self.max_y
self.y_vel = -self.y_vel
self.rect.x = x
self.rect.y = yFinally, there’s a bounce()
method the collision detection scheme uses to change the velocities
of the ball without it having hit a wall. This is pretty silly, but
at least it’s simple:
def bounce( self ):
self.x_vel = -self.x_vel
self.y_vel = -self.y_velThe Door is the simplest class. Aside from the
common attributes, it has one flag, visible, which
governs whether the robot can see the door (and if the collision
detection should take it into account). The entire class is the
constructor:
class Door( GameObject ):
def _ _init_ _( self, image, max_x, max_y ):
self.image = pygame.image.load( image ).convert_alpha( )
self.rect = self.image.get_rect( )
self.max_x = max_x - self.rect.width
self.max_y = max_y - self.rect.height
self.visible = 0
self.radius = self.calc_radius( )
self.place_random( )Only a few odds and ends remain. There are two normal ways to exit
the game: by winning or losing. The game_over()
and win_game() methods provide some small
notification if either has happened. The sleep()
calls ensure that the messages stay visible long enough for people to
read them, then the exit() calls end the program:
def game_over( screen ):
write( screen, "BONK! Game over. So sorry." )
time.sleep( 2 )
sys.exit( )
def win_game( screen ):
write( screen, "YOU WIN! Game over. What a letdown." )
time.sleep( 2 )
sys.exit( )What actually writes the message to the screen? A bit of
pygame.font.Font() hackery. Given the screen to
which to draw and the text of a message, write()
creates a new 36-point font with the default font face, draws it in a
nice blue color to a new surface, aligns the centers of the new
surface and the screen, blits the new surface to the screen, and,
finally, updates the screen. It takes longer to describe than to
write:
def write( screen, message ):
font = pygame.font.Font( None, 36 )
text = font.render( message, 1, ( 0, 0, 255 ) )
text_rect = text.get_rect( )
text_rect.centerx = screen.get_rect( ).centerx
screen.blit( text, text_rect )
pygame.display.flip( )Finally, the last line of code in
bouncy_robot_escape.py actually launches the
program, if someone has invoked it directly from the command line.
This code makes main() the main starting point:
if _ _name_ _ = = '_ _main_ _': main( )
Bouncy Robot Escape is pretty good, for a couple of hours of programming one afternoon and a few minutes of polish a couple of days later. It’s a long way from a finished product, though, needing several enhancements. For example, the entire game logic, right now, is effectively only a single level. Adding a title screen and multiple levels would help, as would sound effects and music.
Also, the ball collision physics aren’t quite right. Balls bounce off immovable walls appropriately, but they bounce off each other incorrectly. Fixing this means tracking their velocities and, on collision, calculating the resulting linear impulse in order to produce new velocities.
Finally, the game is too easy. With the force field as it stands, it’s always possible to beat the level with good reflexes. The force field should have limited uses, or maybe time spent shielded shouldn’t affect the door countdown. It’d also be nice to have internal walls or other stationary obstacles to navigate around.
Still, for a few hours of work, this is pretty good. Hopefully, it’s inspired you to do better and demonstrated that actually writing a game is much easier than you may have thought. It’s the polish that takes time.