Creating Breakout

Creating Breakout

Introduction

  • This article will guide you through creating a breakout game step by step: breakout
  • It covers:
    • Drawing the elements
    • Handling user input
    • Collision detection
    • Animating the ball

Setting up

  • To begin with:
    • Import libraries for math and tkinter.
    • Set up a game class which wraps a canvas object.
    • Create the game object and start the main loop.
  • Running the app at this point will produce an empty blue window: an empty blue window
import math
import tkinter as tk


class Game(tk.Frame):
    width = 600
    height = 400

    def __init__(self, master):
        super(Game, self).__init__(master)

        self.canvas = tk.Canvas(self, bg='#3333dd', width=self.width, height=self.height)
        self.canvas.pack()
        self.pack()


if __name__ == '__main__':
    root = tk.Tk()
    game = Game(root)
    root.mainloop()

Paddle Controller

  • Let's start with the paddle: an empty window with a paddle at the bottom
  • All functionality related to the paddle is collected together into the PaddleController class.
  • It includes functions for moving the paddle left and right, and clamping the position so it doesn't go off the screen.
class PaddleController:
    width = 100
    height = 25
    speed = 20

    def __init__(self, canvas, canvas_width, canvas_height):
        self.canvas = canvas
        self.canvas_width = canvas_width
        self.canvas_height = canvas_height
        self.paddle = canvas.create_rectangle(0, canvas_height - self.height, self.width, canvas_height, fill='#dd3333')

    def left(self):
        position = self.canvas.coords(self.paddle)[0]
        if position < self.speed:
            offset = -position
        else:
            offset = -self.speed

        self.canvas.move(self.paddle, offset, 0)

    def right(self):
        position = self.canvas.coords(self.paddle)[0]
        if position + self.width + self.speed > self.canvas_width:
            offset = self.canvas_width - self.width - position
        else:
            offset = self.speed

        self.canvas.move(self.paddle, offset, 0)
  • And we modify our existing Game class to create the PaddleController and listen to keyboard input:
class Game(tk.Frame):
    def __init__(self, master):
        ...
        self.paddle_controller = PaddleController(self.canvas, self.width, self.height)
        self.__init_keybindings()

    def __init_keybindings(self):
        self.canvas.focus_set()
        self.canvas.bind('<Left>', lambda _: self.paddle_controller.left())
        self.canvas.bind('<Right>', lambda _: self.paddle_controller.right())

Ball Position

  • In Tkinter, the location of a widget is set to its top top-left corner.
  • However we will often have to calculate other positions:
    • The right-edge of a ball will collide with the right wall.
  • We can collect all this logic together into a class called BallPosition.
class BallPosition:
    size: int = 15

    def __init__(self, left, top):
        self._x = left
        self._y = top

    @property
    def left(self):
        return self._x

    @left.setter
    def left(self, l):
        self._x = l

    @property
    def right(self):
        return self._x + self.size

    @right.setter
    def right(self, r):
        self._x = r - self.size

    @property
    def top(self):
        return self._y

    @top.setter
    def top(self, t):
        self._y = t

    @property
    def bottom(self):
        return self._y + self.size

    @bottom.setter
    def bottom(self, b):
        self._y = b - self.size

    @property
    def center_x(self):
        return self._x + self.size / 2

    @center_x.setter
    def center_x(self, cx):
        self._x = cx - self.size / 2

    @property
    def center_y(self):
        return self._y + self.size / 2

    @center_y.setter
    def center_y(self, cy):
        self._y = cy - self.size / 2

Ball Controller

  • Next, we create a ball controller.
  • This contains all the logic for keeping track of the ball, including:
    • Getting and setting the position.
    • Moving the ball based on it's speed.
  • The direction of the ball is split into horizontal and vertical.
    • This way, if we hit a wall then all we need to do is negate the horizontal direction.
class BallController:
    _ball: BallPosition = BallPosition(0, 0)
    ball_tag: int
    ball_direction = [-1, -1]
    speed = 8

    def __init__(self, canvas):
        self.canvas = canvas
        self.ball_tag = canvas.create_oval(0, 0, self._ball.size, self._ball.size, fill='#dddddd')

    def get_ball_position(self):
        return BallPosition(self._ball.left, self._ball.top)

    def set_ball_position(self, ball: BallPosition):
        self.canvas.move(self.ball_tag, ball.left - self._ball.left, ball.top - self._ball.top)
        self._ball = ball

    def peek_next_position(self):
        current_position = self.get_ball_position()
        current_position.left += self.ball_direction[0] * self.speed
        current_position.top += self.ball_direction[1] * self.speed
        return current_position
  • And initialize it in the constructor of the Game class:
class Game(tk.Frame):
    def __init__(self, master):
        ...
        self.ball_controller = BallController(self.canvas)

Syncing the ball to the paddle

  • When the game hasn't started, the ball should be synced to the center of th paddle.
  • We need to:
    1. Create a boolean in the Game class to track whether the game has started.
    2. Add logic to the Paddle Controller to fire when the paddle position changes.
    3. Add logic to the Game class to reset the ball position when the paddle changes.
class PaddleController:
    ...
    on_paddle_change_listeners = []

    def on_paddle_change(self, callback):
        self.on_paddle_change_listeners.append(callback)

    def left(self):
        ...
        for callback in self.on_paddle_change_listeners:
            callback()

    def right(self):
        ...
        for callback in self.on_paddle_change_listeners:
            callback()

    def get_paddle_top_center(self) -> [int]:
        position = self.canvas.coords(self.paddle)
        position[0] += self.width / 2
        return position
class Game(tk.Frame):
    ...
    game_started = False
    ...

    def __init__(self, master):
        ...
        self.paddle_controller.on_paddle_change(self.on_paddle_change)
    
    def on_paddle_change(self):
        if not self.game_started:
            paddle_center = self.paddle_controller.get_paddle_top_center()

            ball_position = self.ball_controller.get_ball_position()
            ball_position.center_x = paddle_center[0]
            ball_position.bottom = paddle_center[1]

            self.ball_controller.set_ball_position(ball_position)
  • At this point, we have a ball that will sync to the position of the paddle: paddle with ball syncing

Start & Stop Game

  • If we run the game now a bug appears.
  • The ball is at the top-left of the screen and only syncs to the paddle position when we move it.
  • We will fix this by implementing a stop_game() function, which also places the text: "Press space to start" on the screen.
  • While we're at it, we'll implement at start_game() function too, and hook it up to the space key.
class Game(tk.Frame):
    def __init__(self, master):
        ...
        self.stop_game()
    
    def stop_game(self):
        self.game_started = False
        self.on_paddle_change()

        self.label = self.canvas.create_text(
            (self.width / 2, self.height / 2),
            text="Press Space To Start",
            font="MSGothic 14 bold", fill="#ffffff")
    
    def start_game(self):
        self.game_started = True
        self.canvas.delete(self.label)

    def __init_keybindings(self):
        ...
        self.canvas.bind('<space>', lambda _: self.start_game())
            
  • Now we have text on the screen, and the ball is above the paddle: with stop_game() implemented

Let's animate

  • So far we can move the paddle and start the game.
  • But nothing actually happens yet.
  • Let's fix that by animating the ball.
  • We will implement a tick() function that runs every 20 milliseconds.
class Game(tk.Frame):
    def __init__(self, master):
        ...
        self.tick()
    
    def tick(self):
        if self.game_started:
            if not self.detect_collisions():
                self.stop_game()

        self.after(50, self.tick)
    
    # returns false when the game is over
    def detect_collisions(self) -> bool:
        next_ball_position = self.ball_controller.peek_next_position()

        self.ball_controller.set_ball_position(next_ball_position)

        return True

Collision Detection

  • The ball now moves when the game starts.
  • But it goes straight through the walls.
  • We need some collision detection.
class Game(tk.Frame):
    # returns false when the game is over
    def detect_collisions(self) -> bool:
        next_ball_position = self.ball_controller.peek_next_position()

        # wall collision
        if self.ball_controller.ball_direction[0] == -1:  # left wall
            if next_ball_position.left <= 0:
                self.ball_controller.ball_direction[0] = 1
                next_ball_position.left = 0
        else:
            if next_ball_position.right >= self.width:  # right wall
                self.ball_controller.ball_direction[0] = -1
                next_ball_position.right = self.width

        # ceiling collision
        if self.ball_controller.ball_direction[1] == -1 and next_ball_position.top <= 0:
            self.ball_controller.ball_direction[1] = 1
            next_ball_position.top = 0

        self.ball_controller.set_ball_position(next_ball_position)

        return True
            

Paddle Collision

  • The ball now bounces off the walls and ceiling.
  • But it doesn't bounce off the paddle, or end the game when it misses the paddle.
  • To implement this we are going to extend our Paddle Controller class, and use the new functions in our collision detection.
class PaddleController:
    # returns true if the point would collide with the paddle if the paddle is in the right place
    def is_in_death_zone(self, y):
        position = self.canvas.coords(self.paddle)
        return y >= position[1]

    def is_collision(self, x) -> (bool, int):
        position = self.canvas.coords(self.paddle)
        return position[0] <= x <= position[0] + self.width, self.canvas_height - self.height

class Game(tk.Frame):
    def detect_collisions(self) -> bool:
        ...
        # paddle collision
        if self.ball_controller.ball_direction[1] == 1:
            if self.paddle_controller.is_in_death_zone(next_ball_position.bottom):
                did_hit, new_y = self.paddle_controller.is_collision(next_ball_position.center_x)
                if did_hit:
                    self.ball_controller.ball_direction[1] = -1
                    next_ball_position.bottom = new_y
                else:
                    return False

        self.ball_controller.set_ball_position(next_ball_position)

        return True
            

Creating Bricks

  • Now that the ball and paddle are done, all that is left for a functioning game is to add some bricks to hit.
  • To do this, we create a brick controller.
  • We store the newly created bricks in an array called bricks that we will reference later.
class BrickController:
    def __init__(self, canvas, canvas_width, canvas_height, num_bricks_x, num_bricks_y):
        self.canvas = canvas
        self.width = canvas_width
        self.height = canvas_height
        self.bricks = []
        self.num_bricks = [num_bricks_x, num_bricks_y]
        self.remaining_bricks = 0

        brick_width = self.width / num_bricks_x
        brick_height = brick_width / 2
        self.brick_size = [brick_width, brick_height]

        for i in range(num_bricks_x):
            self.bricks.append([])
            for j in range(num_bricks_y):
                brick = self.create_brick(i, j)
                if brick is not None:
                    self.remaining_bricks += 1

                self.bricks[i].append(brick)

    def create_brick(self, i, j):
        return self.canvas.create_rectangle(i * self.brick_size[0],
                                            j * self.brick_size[1],
                                            (i + 1) * self.brick_size[0],
                                            (j + 1) * self.brick_size[1],
                                            fill='#33dd33')

class Game(tk.Frame):
    def __init__(self, master):
        ...
        self.brick_controller = BrickController(self.canvas, self.width, self.height, 10, 6)
  • Running the game now will show bricks: breakout game with bricks
  • At the moment, bricks will appear on the screen.
  • But we don't have any collision detection for them.
  • To do this we start by creating a function that works out what 'cell' the ball is in:
    class BrickController:
        def get_cell_containing_point(self, x, y) -> (int, int):
            return math.floor(x / self.brick_size[0]), math.floor(y / self.brick_size[1])
  • We also define what happens if the brick is hit by the ball:
    class BrickController:
        def hit_brick(self, i, j):
            self.canvas.delete(self.bricks[i][j])
            self.bricks[i][j] = None
            self.remaining_bricks -= 1
  • And finally we can create collision detectors for each direction:
    class BrickController:
        # returns true if the collision was successful, along with the y position of the collision point
        def detect_collision_up(self, next_ball_position: BallPosition) -> (bool, int):
            if next_ball_position.top > self.num_bricks[1] * self.brick_size[1]:
                return False, 0
    
            new_cell = self.get_cell_containing_point(next_ball_position.center_x, next_ball_position.top)
    
            if self.bricks[new_cell[0]][new_cell[1]]:
                self.hit_brick(new_cell[0], new_cell[1])
                return True, self.brick_size[1] * (new_cell[1] + 1)
    
            return False, 0
    
        # returns true if the collision was successful, along with the y position of the collision point
        def detect_collision_down(self, next_ball_position: BallPosition) -> (bool, int):
            if next_ball_position.bottom > self.num_bricks[1] * self.brick_size[1]:
                return False, 0
    
            new_cell = self.get_cell_containing_point(next_ball_position.center_x, next_ball_position.bottom)
    
            if self.bricks[new_cell[0]][new_cell[1]]:
                self.hit_brick(new_cell[0], new_cell[1])
                return True, self.brick_size[1] * new_cell[1]
    
            return False, 0
    
        # returns true if the collision was successful, along with the x position of the collision point
        def detect_collision_left(self, next_ball_position: BallPosition) -> (bool, int):
            if next_ball_position.center_y > self.num_bricks[1] * self.brick_size[1]:
                return False, 0
    
            new_cell = self.get_cell_containing_point(next_ball_position.left, next_ball_position.center_y)
    
            if self.bricks[new_cell[0]][new_cell[1]]:
                self.hit_brick(new_cell[0], new_cell[1])
                return True, self.brick_size[0] * (new_cell[0] + 1)
    
            return False, 0
    
        # returns true if the collision was successful, along with the x position of the collision point
        def detect_collision_right(self, next_ball_position: BallPosition) -> (bool, int):
            if next_ball_position.center_y > self.num_bricks[1] * self.brick_size[1]:
                return False, 0
    
            new_cell = self.get_cell_containing_point(next_ball_position.right, next_ball_position.center_y)
    
            if self.bricks[new_cell[0]][new_cell[1]]:
                self.hit_brick(new_cell[0], new_cell[1])
                return True, self.brick_size[0] * new_cell[0]
    
            return False, 0
  • All that is left is to call these collision detection functions from our Game class:
    class Game(tk.Frame):
        def detect_collisions(self) -> bool:
            ...
            # collision up
            if self.ball_controller.ball_direction[1] == -1:
                did_hit, new_y = self.brick_controller.detect_collision_up(next_ball_position)
                if did_hit:
                    self.ball_controller.ball_direction[1] = 1
                    next_ball_position.top = new_y
            else:  # collision down
                did_hit, new_y = self.brick_controller.detect_collision_down(next_ball_position)
                if did_hit:
                    self.ball_controller.ball_direction[1] = -1
                    next_ball_position.bottom = new_y
    
            # collision left
            if self.ball_controller.ball_direction[0] == -1:
                did_hit, new_x = self.brick_controller.detect_collision_left(next_ball_position)
                if did_hit:
                    self.ball_controller.ball_direction[0] = 1
                    next_ball_position.left = new_x
            else:  # collision right
                did_hit, new_x = self.brick_controller.detect_collision_right(next_ball_position)
                if did_hit:
                    self.ball_controller.ball_direction[0] = -1
                    next_ball_position.right = new_x
  • And we can end our detect_collisions() function with a check to see if there are any blocks remaining:
    class Game(tk.Frame):
        def detect_collisions(self) -> bool:
            if self.brick_controller.remaining_bricks == 0:
                return False
            else:
                return True
  • If you run the game now you will see the ball hitting the bricks and removing them: breakout game with brick collisions

Adding extras

  • There are many more features that can be added to the game.
  • Let's add the ability for bricks to have multiple hits before they disappear.
  • After each hit, the brick changes color.
  • To do this, we will introduce a new concept: a 'view':
    • So far all our graphical objects have been simple.
    • Either a rectangle or circle that does nothing but change position.
    • As the game gets more complex, we create classes called views to encapsulate this complexity.
  • We create a BrickView that keeps track of how many times each brick has been hit.
    class BrickView:
        hits: int
        tag: int
  • Instead of modifying our existing BrickController, we create a new one, and use inheritance and override the behavior:
    class BrickController2(BrickController):
        colors = ['#337733', '#33aa33', '#33dd33']
    
        def __init__(self, canvas, canvas_width, canvas_height, num_bricks_x, num_bricks_y):
            super().__init__(canvas, canvas_width, canvas_height, num_bricks_x, num_bricks_y)
    
        def create_brick(self, i, j):
            brick_view = BrickView()
            brick_view.tag = self.canvas.create_rectangle(i * self.brick_size[0],
                                                j * self.brick_size[1],
                                                (i + 1) * self.brick_size[0],
                                                (j + 1) * self.brick_size[1],
                                                fill=self.colors[0])
            brick_view.hits = 0
            return brick_view
    
        def hit_brick(self, i, j):
            brick_view: BrickView = self.bricks[i][j]
            if brick_view.hits < 2:
                brick_view.hits += 1
                self.canvas.itemconfig(brick_view.tag, fill=self.colors[brick_view.hits])
            else:
                self.canvas.delete(brick_view.tag)
                self.bricks[i][j] = None
                self.remaining_bricks -= 1
  • And finally use this new controller in our Game class:
    class Game(tk.Frame):
        def __init__(self, master):
            ...
            self.brick_controller = BrickController2(self.canvas, self.width, self.height, 10, 6)
                        
  • If you run the game now you will see bricks with multiple hits that change color: breakout

Conclusion

  • Structuring the code into controllers and views keeps the code clean and efficient.
  • You may have noticed that none of the controllers directly reference each other:
    • This is on purpose, having controllers that call other controllers quickly turns into a mess.
    • If two controllers do need to interact, then events can be used:
      • Such as updating the ball position via on_paddle_change()
  • Next steps for this game would be to:
    1. Create a menu system.
    2. Add multiple levels.
    3. Add a number of 'lives' a player has.
    4. Add sound.