Chapter 10. The Project#
In this chapter we will apply the concepts we learned in the previous chapters and create a clone of the Breakout game. The goal of this game is to use a paddle to hit a ball and destroy bricks. You win, if all the bricks are destroyed and you lose if the ball touches the bottom wall:
We will use the pygame
package designed for writing games in Python. You can install it using pip install --user pygame
(no surprises there if you followed the last chapter).
The Game Loop#
We begin by writing the game loop. Here we create the screen
object which we draw onto. We also create a clock
that will allow us to control how often we want to render a frame (for this game we want to render 30
frames per second). We also define a while
loop which repeatedly executes the following steps as long as the game is active:
Process all currently existing user events. Right now we only process the
pygame.QUIT
event which happens if the user closes the window. If we see apygame.QUIT
event we set theactive
flag toFalse
which will exit the game loop and therefore also exit the application (since there is no code following the game loop).Update the clock using the
tick
method.Repaint the screen using
screen.fill
and update it by callingpygame.display.flip
. All game objects will be drawn between those two method calls.
Here is how the corresponding code looks like:
import pygame
w, h = 640, 480
pygame.init()
screen = pygame.display.set_mode((w, h))
pygame.display.set_caption("Breakout")
clock = pygame.time.Clock()
active = True
while active:
for event in pygame.event.get():
if event.type == pygame.QUIT:
active = False
# more events will be processed here
clock.tick(30)
screen.fill((255, 255, 255))
# game objects will be drawn here
pygame.display.flip()
The Paddle#
Next we need to create our game objects. First we create a paddle that we can move around using our mouse.
The paddle needs to have the attributes x
, y
, w
and h
which denote its position and its dimensions. It also needs to have the usual __repr__
and __eq__
methods. Additionaly it should have (like all game objects) a render
method which takes the screen
object and renders the paddle onto the screen. Finally we need a move
method which attempts to move the paddle to the respective mouse position.
Here is a first stab at the Paddle
class:
class Paddle:
def __init__(self, x, y, w, h):
self.x = x
self.y = y
self.w = w
self.h = h
def move(self, mouse_pos):
mouse_x, _ = mouse_pos
self.x = int(mouse_x - self.w / 2)
def render(self, screen):
pygame.draw.rect(screen, (0, 127, 0), pygame.Rect(self.x, self.y, self.w, self.h))
def __eq__(self, other):
if not isinstance(other, Paddle):
return False
return self.x == other.x and self.y == other.y and self.w == other.w and self.h == other.h
def __repr__(self):
return f"Paddle(x={self.x}, y={self.y}, w={self.w}, h={self.h})"
Most of this is pretty self-explanatory except for the move
method. We can’t move the paddle to the mouse position, because we want the paddle to always be anchored to the bottom of the screen. Therefore we ignore the y coordinate of the mouse position completely and only focus on the x coordinate. To make the movement feel right, we take the x coordinate of the mouse to be the center of the paddle.
This means that after the move mouse_x
must be equal to self.x + self.w / 2
(since self.x
indicates the left border of the paddle). Basic arithmetic therefore gives us the formula for the update: self.x = mouse_x - self.w / 2
.
Let’s create a paddle
object and play around with it:
paddle = Paddle(x=280, y=460, w=80, h=20)
paddle
Paddle(x=280, y=460, w=80, h=20)
paddle.move((240, 320))
paddle
Paddle(x=200, y=460, w=80, h=20)
This looks quite good! Now we need to add the paddle to our game. In order to accomplish this, two things need to be done. First, we need to check for pygame.MOUSEMOTION
events and update the paddle position if such an event occurs. Second, we need to render the paddle in the game loop.
Here is the code we need to add:
paddle = Paddle(x=280, y=460, w=80, h=20)
...
while active:
for event in pygame.event.get():
...
if event.type == pygame.MOUSEMOTION:
paddle.move(event.pos)
...
clock.tick(30)
screen.fill((255, 255, 255))
paddle.draw(screen)
pygame.display.flip()
...
Our game now looks like this:
Try moving the paddle around with your mouse and verify that the paddle moves along.
The Ball#
Next we need to add the ball. The ball should have a position x
and y
, a direction dx
and dy
and a radius r
. The ball is not controlled by the player. Instead it simply moves on every frame by the amount given by dx
and dy
.
Here is how the Ball
class looks like:
class Ball:
def __init__(self, x, y, dx, dy, r):
self.x = x
self.y = y
self.dx = dx
self.dy = dy
self.r = r
def move(self):
self.x += self.dx
self.y += self.dy
def render(self, screen):
pygame.draw.circle(screen, (0, 127, 0), (self.x, self.y), self.r)
def __eq__(self, other):
if not isinstance(other, Ball):
return False
return self.x == other.x and self.y == other.y and self.dx == other.dx and self.dy == other.dy and self.r == other.r
def __repr__(self):
return f"Ball(x={self.x}, y={self.y}, dx={self.dx}, dy={self.dy}, r={self.r})"
Again we have the special methods __eq__
and __repr__
. We also have the render
method which renders the ball onto the screen. Finally, we have a move
method which updates the position of the ball.
Let’s create a ball
object and have a look at its functionality:
ball = Ball(x=320, y=240, dx=-2, dy=2, r=10)
ball
Ball(x=320, y=240, dx=-2, dy=2, r=10)
ball.move()
ball
Ball(x=318, y=242, dx=-2, dy=2, r=10)
Nice work! Now we add a ball to the game loop. Since the ball doesn’t need to respond to external events, this is even simpler than the paddle. We create a ball
object that starts at the center of the screen, call the move
method after every tick and render the ball:
paddle = Paddle(x=280, y=460, w=80, h=20)
ball = ball = Ball(x=w // 2, y=h // 2, dx=-8, dy=8, r=10)
...
while active:
...
clock.tick(30)
ball.move()
screen.fill((255, 255, 255))
paddle.draw(screen)
pygame.display.flip()
Our game now looks like this:
If you run the game now, you will see a tiny problem - the ball moves through the bottom wall and disappears. This is because we currently have no collision detection. Since dy
is negative, the ball just keeps moving downwards until it’s no longer inside the playing area.
The algorithm for collision detection will be relatively straightforward. We check if the ball collides with a wall and if that’s the case, we will simply reverse its direction. This leads to a problem. Finding out if the ball collides with one of the walls requires knowledge about the dimension of the screen.
We could pass that information along to the ball object, but that doesn’t seem right. Additionally, we will later need to coordinate multiple objects anyway (for example we will have to check for collisions between balls and bricks). In order to accomplish this, we create a Game
class which will coordinate our game objects.
The Game Class#
Let’s think about the way the Game
class should work. It needs to expose two methods - an update
method that will update the game and a render
method that will render the game. Here is how that might look like in code:
class Game:
def __init__(self, w, h, paddle, ball):
self.w = w
self.h = h
self.paddle = paddle
self.ball = ball
def update(self):
self.ball.move()
def render(self, screen):
screen.fill((255, 255, 255))
self.paddle.render(screen)
self.ball.render(screen)
pygame.display.flip()
def __repr__(self):
return f"Game(w={self.w}, h={self.h}, ball={self.ball}, paddle={self.paddle})"
Let’s play around with the Game
class:
w, h = 640, 480
paddle = Paddle(x=280, y=460, w=80, h=20)
ball = Ball(x=w // 2, y=h // 2, dx=-8, dy=8, r=10)
game = Game(w, h, paddle, ball)
game
Game(w=640, h=480, ball=Ball(x=320, y=240, dx=-8, dy=8, r=10), paddle=Paddle(x=280, y=460, w=80, h=20))
game.update()
game
Game(w=640, h=480, ball=Ball(x=312, y=248, dx=-8, dy=8, r=10), paddle=Paddle(x=280, y=460, w=80, h=20))
game.paddle.move((240, 320))
game
Game(w=640, h=480, ball=Ball(x=312, y=248, dx=-8, dy=8, r=10), paddle=Paddle(x=200, y=460, w=80, h=20))
This looks good. Now we update the game loop. Instead of updating and rerendering all game objects separately, the game
object will now take care of updates and renders:
clock = pygame.time.Clock()
paddle = Paddle(x=280, y=460, w=80, h=20)
ball = Ball(x=w // 2, y=h // 2, dx=-8, dy=8, r=10)
game = Game(w, h, paddle, ball, 8, 6)
active = True
while active:
for event in pygame.event.get():
if event.type == pygame.QUIT:
active = False
if event.type == pygame.MOUSEMOTION:
paddle.move(event.pos)
clock.tick(30)
game.update()
game.render(screen)
Much more readable. In addition, we will practically never have to change the game loop again. If we want to add new objects to game, all we need to do is to update the Game
class.
Note that no functionality has changed. We just refactored our code - we spent some time cleaning up and improving the structure in order to make it easier to make changes later on. Regular refactoring is an absolutely vital part of software development - otherwise you will soon find yourself with a mess of utterly unreadable spaghetti code.
Let’s refactor some more and split the different pieces into different modules.
Splitting the Project into Modules#
Create a file paddle.py
and move the Paddle
class into that file. Here is how paddle.py
should look like now:
import pygame
class Paddle:
def __init__(self, x, y, w, h):
self.x = x
self.y = y
self.w = w
self.h = h
def move(self, mouse_pos):
mouse_x, _ = mouse_pos
self.x = int(mouse_x - self.w / 2)
def render(self, screen):
pygame.draw.rect(screen, (0, 127, 0), pygame.Rect(self.x, self.y, self.w, self.h))
def __eq__(self, other):
if not isinstance(other, Paddle):
return False
return self.x == other.x and self.y == other.y and self.w == other.w and self.h == other.h
def __repr__(self):
return f"Paddle(x={self.x}, y={self.y}, w={self.w}, h={self.h})"
Next create a file ball.py
and move the Ball
class into that file. Here is how ball.py
should look like now:
import pygame
class Ball:
def __init__(self, x, y, dx, dy, r):
self.x = x
self.y = y
self.dx = dx
self.dy = dy
self.r = r
def move(self):
self.x += self.dx
self.y += self.dy
def render(self, screen):
pygame.draw.circle(screen, (0, 127, 0), (self.x, self.y), self.r)
def __eq__(self, other):
if not isinstance(other, Ball):
return False
return self.x == other.x and self.y == other.y and self.dx == other.dx and self.dy == other.dy and self.r == other.r
def __repr__(self):
return f"Ball(x={self.x}, y={self.y}, dx={self.dx}, dy={self.dy}, r={self.r})"
We also move the Game
into a file game.py
:
class Game:
def __init__(self, w, h, paddle, ball):
self.w = w
self.h = h
self.paddle = paddle
self.ball = ball
def update(self):
self.ball.move()
def render(self, screen):
screen.fill((255, 255, 255))
self.paddle.render(screen)
self.ball.render(screen)
pygame.display.flip()
def __repr__(self):
return f"Game(w={self.w}, h={self.h}, ball={self.ball}, paddle={self.paddle})"
Finally let’s move the game loop to a file breakout.py
:
import pygame
from ball import Ball
from game import Game
from paddle import Paddle
w, h = 640, 480
pygame.init()
screen = pygame.display.set_mode((w, h))
pygame.display.set_caption("Breakout")
clock = pygame.time.Clock()
paddle = Paddle(x=280, y=460, w=80, h=20)
ball = Ball(x=w // 2, y=h // 2, dx=-8, dy=8, r=10)
game = Game(w, h, paddle, ball, 8, 6)
active = True
while active:
for event in pygame.event.get():
if event.type == pygame.QUIT:
active = False
if event.type == pygame.MOUSEMOTION:
paddle.move(event.pos)
clock.tick(30)
game.update()
game.render(screen)
Collision Detection#
Now that we have refactored our code, adding collisions with the paddle and the wall is pretty easy.
Let’s write a helper function that checks if a rectangle collides with a circle. We will keep it really simple and just check whether the center of the circle is inside the rectangle. Add that function to game.py
:
def coords_in_rect(x, y, rect_x, rect_y, rect_w, rect_h):
return rect_x < x < rect_x + rect_w and rect_y < y < rect_y + rect_h
Now we update the Game
class:
class Game:
...
def update(self):
self.ball.move()
# If the ball collides with the paddle, we reverse its direction
if coords_in_rect(self.ball.x, self.ball.y, self.paddle.x, self.paddle.y, self.paddle.w, self.paddle.h):
self.ball.dy *= -1
We can now hit the ball with the paddle, but the ball still disappears through the wall. We need to check for wall collisions as well:
class Game:
...
def update(self):
self.ball.move()
# If the ball collides with the paddle, we reverse its direction
if coords_in_rect(self.ball.x, self.ball.y, self.paddle.x, self.paddle.y, self.paddle.w, self.paddle.h):
self.ball.dy *= -1
# Check for wall collisions
if self.ball.x - self.ball.r < 0 or self.ball.x + self.ball.r > self.w:
self.ball.dx *= -1
if self.ball.y - self.ball.r < 0 or self.ball.y + self.ball.r > self.h:
self.ball.dy *= -1
Adding the Bricks#
We now have a ball that bounces around and can be hit with a paddle. However, we are still missing the actual game, i.e. the possibility to hit some bricks with our ball.
Let’s create a file brick.py
containing the Brick
class:
class Brick:
def __init__(self, x, y, w, h):
self.x = x
self.y = y
self.w = w
self.h = h
def render(self, screen):
pygame.draw.rect(screen, (0, 127, 0), pygame.Rect(self.x, self.y, self.w, self.h))
def __eq__(self, other):
if not isinstance(other, Brick):
return False
return self.x == other.x and self.y == other.y and self.w == other.w and self.h == other.h
def __repr__(self):
return f"Brick(x={self.x}, y={self.y}, w={self.w}, h={self.h})"
We will also add some helper functions that create a list of bricks for our game in brick.py
:
def get_brick_w(w, n_bricks_x):
return 2 * w / (3 * n_bricks_x + 1)
def get_brick_h(h, n_bricks_y):
return h / (3 * n_bricks_y + 1)
def get_bricks(n_bricks_x, n_bricks_y, brick_w, brick_h):
bricks = []
for i in range(0, n_bricks_x):
for j in range(0, n_bricks_y):
brick = Brick(
x=i * brick_w + (i + 1) * brick_w / 2,
y=j * brick_h + (j + 1) * brick_h / 2,
w=brick_w,
h=brick_h,
)
bricks.append(brick)
return bricks
Now we add the bricks to the Game
class:
...
from brick import get_brick_w, get_brick_h, get_bricks
...
class Game:
def __init__(self, w, h, ball, paddle, n_bricks_x, n_bricks_y):
...
self.n_bricks_x = n_bricks_x
self.n_bricks_y = n_bricks_y
brick_w = get_brick_w(w, n_bricks_x)
brick_h = get_brick_h(h, n_bricks_y)
self.bricks = get_bricks(n_bricks_x, n_bricks_y, brick_w, brick_h)
Don’t forget to update the game
object in breakout.py
:
game = Game(w, h, paddle, ball, 8, 6)
Rendering the bricks is pretty simple. We just iterate through all the bricks in the render
method and render each brick:
class Game:
...
def render(self, screen):
screen.fill((255, 255, 255))
self.paddle.render(screen)
self.ball.render(screen)
# Render the bricks
for brick in self.bricks:
brick.render(screen)
pygame.display.flip()
We also destroy the bricks that are hit by the ball. If at least one brick is hit, we reverse the balls direction:
class Game:
...
def update(self):
...
prev_len = len(self.bricks)
self.bricks = [brick for brick in self.bricks if not coords_in_rect(self.ball.x, self.ball.y, brick.x, brick.y, brick.w, brick.h)]
if len(self.bricks) < prev_len:
self.ball.dy *= -1
This looks pretty neat:
Ending the Game#
We are pretty much done, all that is left to do is end the game when the player wins or loses. To accomplish that we will maintain a game state which will be one of "ongoing"
, "won"
or "lost"
and render different things depending on the game state.
First, we update the Game
class:
class Game:
def __init__(self, w, h, paddle, ball, n_bricks_x, n_bricks_y):
...
self.state = "ongoing"
self.font = pygame.font.SysFont("Arial", 30)
def update(self):
...
if self.ball.y + self.ball.r > self.h:
self.state = "lost"
...
if len(self.bricks) == 0:
self.state = "won"
Next we update the render
method of the Game
class:
class Game:
...
def render(self, screen):
if self.state == "ongoing":
screen.fill((255, 255, 255))
self.paddle.render(screen)
self.ball.render(screen)
for brick in self.bricks:
brick.render(screen)
pygame.display.flip()
else:
screen.fill((255, 255, 255))
surface = self.font.render(f"You {self.state}", False, (0, 127, 0))
text_rect = surface.get_rect(center=(self.w / 2, self.h / 2))
screen.blit(surface, text_rect)
pygame.display.flip()
...
Finally we need to call pygame.font.init()
in breakout.py
:
pygame.init()
pygame.font.init()
screen = pygame.display.set_mode((w, h))
Now if we destroy all bricks we win the game:
Similarly if the ball disappers throught the bottom of the screen we lose the game.
Wrapping Up#
Congratulations, you just completed your first game! From here, there are a number of things you can try by yourself:
The paddle currently partially disappears, if we move the mouse too far left or too far right. This can be improved by checking whether there is a collision between the paddle and a wall.
Try adding a score counter to the game.
Improve the collision detection between the ball and the various game elements.
Add a timer to the game, so that the player only has a limited amount of time to destroy all the bricks!
Improve the user experience. The game looks quite flat and boring. This could be fixed by playing around with colors, shadows etc.
Allow the player to restart the game by pressing a special key after he won or lost the game.
And many more… The only limit is your imagination!