Introduction
- This article will guide you through creating a breakout game step by step:
- 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:
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:
- 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:
- Create a boolean in the Game class to track whether the game has started.
- Add logic to the Paddle Controller to fire when the paddle position changes.
- 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:
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:
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:
- 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:
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:
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:
- Create a menu system.
- Add multiple levels.
- Add a number of 'lives' a player has.
- Add sound.