Pong with Turtle Graphics: Part 2
In a previous post — Games with Turtle Graphics — I proposed a general game plan 😁 for implementing Pong with Turtle graphics. In this post and the previous, I explain how to translate that game plan to working code, piece by piece. In part 1 we put the building blocks in place, and in this post we’ll implement the game’s logic.
Note: As we are just beginning to learn how to model a system, the code below is not optimal. It is also verbose to make what is happening very clear, and there are a number of edge-cases that are not handled.
Approach
Now that we’ve created all of the visual elements, let’s zoom out and use descriptive function names to capture the general flow of the game’s logic, before we then zoom in and code each of those functions in turn.
As explained in the Animation with Turtle Graphics post, we repeatedly need to display the screen as a frame many times a second to bring the game and its animations to life. We’d like the screen not to display any frames unless we explicitly tell it to, so we must remember to add the following right after creating our screen:
screen.tracer(0)
For each frame, our program must then do all of the following:
- Check if someone scores, updating the score variables and text if necessary.
- Update the paddle positions if the players are moving them.
- Update the ball position.
- Display the new frame.
- Schedule the next frame to be called a fraction of a second later.
Let’s write the code for that:
(Note: The code below obviously can’t run, as we haven’t yet created the functions or variable!)
def frame () :
check_if_someone_scores()
update_paddle_positions()
update_ball_position()
screen.update() # show the new frame
screen.ontimer(frame, framerate_ms) # schedule this function to be called again a bit later
On line 6 above, the function itself takes care of letting the screen know to call it again a bit later. When called later it does all those things again, and once more tells the screen to call it a bit later still, and so on. To kick off the game, we’d then only have to manually call the frame
function once:
# START THE GAME
framerate_ms = 40 # Every how many milliseconds must screen call frame function?
frame()
By using the screen.ontimer(callback, time)
function we have precise control over the frame rate of our game: each frame runs framerate_ms
milliseconds after the end of the previous frame. During the time between the end of one frame and the start of the next, the screen can take care of other things. If we were to use a while
loop instead that would not be the case, and the speed of the game would be dependent on the speed of the computer it was played on.
Ball movement
Let’s first focus on getting the ball moving. What is the speed and direction of the ball’s movement going to be, or stated differently, how would we like to manage where the ball should move each frame? Let’s split the ball’s movement into a horizontal and a vertical component, and create two global variables to represent them. For each frame we can then add that many horizontal units to the x component of the ball’s current position, and add that many vertical units to the y. By making those numbers bigger or smaller, the ball will move faster or slower. If the horizontal component is negative the ball will move left, if it is positive the ball will move right, and if 0 the ball won’t move horizontally. The same goes for the vertical component, so by adjusting these two values we can send the ball anywhere on the screen at whatever speed we like. Let’s plan to start the ball moving up and to the right by making both values positive. The diagonal pink line in the image below then shows how the ball would appear to move in four consecutive frames:
Let’s now implement the update_ball_position
function as well as empty check_if_someone_scores
and update_paddle_positions
functions, so that our code can run and we can test whether the ball is moving as planned:
# Ball movement
ball_move_horiz = 3 # Horizontal movement per frame
ball_move_vert = 2 # Vertical movement per frame
def update_ball_position () :
global ball_move_horiz, ball_move_vert
ball.setx(ball.xcor() + ball_move_horiz)
ball.sety(ball.ycor() + ball_move_vert)
def check_if_someone_scores() :
pass
def update_paddle_positions() :
pass
We find that the ball does indeed move correctly! However, it moves right through everything else as it’s only a turtle that has no awareness of other objects, so let’s consider that next.
Ball collisions
We need to check whether the ball appears to be colliding with anything, and respond appropriately. As a turtle’s position is the centre of the turtle’s shape we cannot only use the ball’s position to compare — we want to use the outside edge of the ball’s shape instead, so that it looks like a collision in the real world would. This is where the ball_radius
variable that we created earlier comes in handy. To find the y coordinate of the top of the ball, we simply add the radius to the y component of its position, given by a turtle’s ycor()
function. Or to find the x coordinate of the left of the ball, we simply subtract the radius from the x component of its position, given by xcor()
:
Bouncing off walls
As we’ve made our ball to move upwards at the start of the game, the first collision we could check for is with the top of the playing field. We’ve already got the play_top
variable, and we can just compare the y coordinate of the top of the ball to it to know whether the ball has reached the top. We can similarly also compare the y coordinate of the bottom of the ball to the play_bottom
variable. It’s not quite as simple as checking if the y valyes are exactly equal to each other because we move the ball by a fixed (discrete) number of units for every frame. In other words, if we’re moving the ball by say 10 units vertically for each frame, the top of the ball might be 8 units below the top of the playing field in the first frame, and in the next frame it will be 2 units above, as we’ve moved it up by 10 units. So to know whether they have collided, we should check whether the top of the ball is at the same height as or higher than the top of the playing field. Of course, as collisions are checked for each frame, it won’t be higher by more than ball_move_vert
units as that is maximum vertical amount that the ball moves in each frame. The bottom, as well as other collisions, would work in a similar way.
What should happen if the ball does appear to reach either the bottom or the top? We would like it to look like the ball bounces off, so let’s change the vertical component of the direction that the ball was travelling. If the ball was travelling up, the ball_move_vert
variable would have had a positive value, and we can make it negative to make the ball start moving down. Similarly if the ball was travelling down, the ball_move_vert
variable would have had a negative value, and we can make it positive to make the ball start moving up instead. This means that whenever the outside edge of the ball appears to reach either the top or bottom of the playing field (either exactly, or by going slightly beyond it), we simply have to multiply the ball_move_vert
variable by -1
to make it move in the opposite vertical direction again:
def update_ball_position () :
global ball_move_horiz, ball_move_vert
if ball.ycor() + ball_radius >= play_top : # top of ball at or above top of field
ball_move_vert *= -1
elif play_bottom >= ball.ycor() - ball_radius : # bottom of ball at or below bottom of field
ball_move_vert *= -1
ball.setx(ball.xcor() + ball_move_horiz)
ball.sety(ball.ycor() + ball_move_vert)
The ball now appears to bounce off the top and bottom of the playing field!
Scoring
We should have a similar check inside the check_if_someone_scores
function, using the left and right sides of the ball to check whether the ball has reached the left or right side of the playing field. If it has, we should give one point to the other player and update the display of the score. We should also move the ball back to the middle so that the game can continue. To make the game interesting, it’s a good idea to give the ball a random speed and direction (within limits) each time it starts again from the middle. We can do this by importing and using one of the functions from Python’s random module, such as the randint function:
from random import randint
...
def reset_ball() :
global ball_move_vert, ball_move_horiz
ball.setpos(0, 0)
speed_horiz = randint(2,4)
speed_vert = randint(2,4)
direction_horiz = 1
direction_vert = 1
if randint(0,100) > 50 : # 50% chance of going left instead of right
direction_horiz = -1
if randint(0,100) > 50 : # 50% chance of going down instead of up
direction_vert = -1
ball_move_horiz = direction_horiz * speed_horiz
ball_move_vert = direction_vert * speed_vert
def check_if_someone_scores() :
global score_L, score_R
if (ball.xcor() + ball_radius) >= play_right : # right of ball at right of field
score_L += 1
write_scores()
reset_ball()
elif play_left >= (ball.xcor() - ball_radius) : # left of ball at left of field
score_R += 1
write_scores()
reset_ball()
Bouncing off paddles
Finally, we should also have a similar check inside the update_ball_position
function to check whether the ball is colliding with a paddle. If the ball does collide with a paddle, we can reverse its horizontal movement in the same way we reversed its vertical movement earlier when it hits the top or bottom of the playing field. Let’s add the check as a function call inside the update_ball_position
function, to which we can pass the paddle to compare to each time:
...
if ball_collides_with_paddle(R) or ball_collides_with_paddle(L) :
ball_move_horiz *= -1
...
To calculate whether the edge of the ball overlaps with the edge of the paddle we have slightly more work to do as there are two paddles, and they have both back and front sides. In the same way that we had to use the radius together with the ball’s position to find the edge of the ball, to find the four sides of a paddle we have to use half of the width and half the height together with the paddle’s position. When the ball and paddle appear exactly next to each other, the horizontal distance between the turtle positions will be equal to the radius of the ball + half the width of the paddle. And if they overlap, it will be even less than than. The vertical distance will work in a similar way, using the paddle_h_half
variable for the paddle instead when calculating the vertical overlap:
The way to find the distance between two objects is to subtract the position of the one from the position of the other. However, that might give us a negative answer if we’ve subtracted the bigger value from the smaller, although the size of the number will still be the same: the negative or positive sign simply tells us on which side the other object is. For example, 10 - 5 = 5
and 5 - 10 = -5
. By taking the absolute value, we can work with the distance between the two objects regardless of which side either of the objects is on. This then allows us to use the same function for both the R and L paddle, and to take into account both sides of each paddle as well:
def ball_collides_with_paddle (paddle) :
x_distance = abs(paddle.xcor() - ball.xcor())
y_distance = abs(paddle.ycor() - ball.ycor())
overlap_horizontally = (ball_radius + paddle_w_half >= x_distance) # either True or False
overlap_vertically = (ball_radius + paddle_h_half >= y_distance) # either True or False
return overlap_horizontally and overlap_vertically # so it returns either True or False
However, the method above isn’t precise for a circle. For a rectangle like the paddle, the horizontal distance from its centre point to either the left or right edge is constant (equal to exactly paddle_w_half
) regardless of your vertical position on that edge, and that’s why the edge forms a vertical line. But for an ellipse like the ball, the horizontal distance from its centre point to the outside edge is not constant — the combination of the x and y components / horizontal and vertical distances is constant and equal to ball_radius
, but the x and y components themselves aren’t constant. The relationship between the three is described by the formula:
radius2 = x2 + y2
So by comparing the ball’s radius to the x component of its position regardless of the y component, and the same for the y component, we’re actually working with the ball as if it were a square. Such an imaginary box is called a bounding box, and is commonly used in gaming when detecting collisions as it simplifies the calculations. The implication is that our ball_collides_with_paddle
function would report a collision when the ball is close to a corner of the paddle even though the ball isn’t touching the paddle yet:
You could improve the method if you’d like to. However the imprecision should hardly be noticeable as the distances are very small and the frame rate high, so I’m going to leave it like that. But it’s important to be aware of the limitations of the method we have chosen for calculating collisions between ball and paddles.
Paddle movement
The final thing that remains for us to do to have a working game, is the movement of the paddles when the users push specific keys.
Keyboard keys
We can listen for specific key presses by using the screen.onkeypress(callback_function, key_name)
function. Let’s assign the w and z keys to L’s up and down movement, and the up ↑ and down ↓ arrow keys to R’s up and down movement. We then have to implement the callback functions. However, inside each function, we don’t actually want to move the paddles. To understand why, imagine that we deliberately made our game slow by giving it a very slow frame rate, with a new frame being calculated and drawn only every second. Even though the user might be able to press a key 10 times during that second, we don’t want their paddle to move 10 times. It makes more sense for the game’s speed to determine the speed of everything in the game, including the paddles. The user also wouldn’t be able to see where their paddle is until the next frame is drawn, which could be very confusing if they could move it many times. So we’d prefer to match the paddle’s movement or frame rate to the game’s frame rate. This means that inside the callback functions we only actually want to record in which direction that paddle should move in the next frame. We can do this by making a variable for each paddle that controls its movement direction. Let’s decide that a value of 0 for that variable means no movement, -1 means down, and 1 means up. That would make it easy to simply add that variable to the paddle’s current y position to make it move either up, down, or not at all:
# Should L/R paddle move up/down in next frame?
paddle_L_move_direction = 0
paddle_R_move_direction = 0
def L_up() :
global paddle_L_move_direction
paddle_L_move_direction = 1
def L_down() :
global paddle_L_move_direction
paddle_L_move_direction = -1
def R_up() :
global paddle_R_move_direction
paddle_R_move_direction = 1
def R_down() :
global paddle_R_move_direction
paddle_R_move_direction = -1
screen.onkeypress(L_up, "w")
screen.onkeypress(L_down, "z")
screen.onkeypress(R_up, "Up")
screen.onkeypress(R_down, "Down")
In the frame
function, our update_paddle_positions
function can then process the actual movement. This means that we also need to keep track of when the user is no longer pressing the keys, as we then no longer want the paddle to move, so let’s also add callback functions for when those same keys are released. We can use the same function for the release of both up and down keys, as we simply want to record that the user should no longer be moving when they let go of either key. We must also remember to call the screen.listen()
function if we want the screen to start listening for key presses:
def L_off() :
global paddle_L_move_direction
paddle_L_move_direction = 0
def R_off() :
global paddle_R_move_direction
paddle_R_move_direction = 0
screen.onkeyrelease(L_off, "w")
screen.onkeyrelease(L_off, "z")
screen.onkeyrelease(R_off, "Up")
screen.onkeyrelease(R_off, "Down")
screen.listen()
Note that this means that should the player press both their up and down keys at the same time and then only let go of the one, their paddle will still stop moving. You can of course change it if you prefer.
Movement
Finally, we must move the paddles. The move-direction variable that we created for each paddle tells us in which direction that paddle should be moved. However, we haven’t decided how far it should be moved each frame, or stated differently, how fast a paddle should appear to move. Let’s create a variable for that just like we did with the ball. To calculate where the paddle should be moved to, we can use the paddle’s current position and add that many units in the necessary direction. Then we again need to check whether the new position isn’t a problem, as we want the paddle to stay inside the screen. If the top of the paddle would exit the top of the screen, we shouldn’t move it. The same goes for the bottom of the paddle at the bottom of the screen. But if the new position isn’t a problem, we can move the paddle there:
paddle_move_vert = 4 # Vertical movement per frame
def paddle_is_allowed_to_move_here (new_y_pos) :
if (play_bottom > new_y_pos - paddle_h_half) : # bottom of paddle below bottom of field
return False
if (new_y_pos + paddle_h_half > play_top) : # top of paddle above top of field
return False
return True
def update_paddle_positions () :
L_new_y_pos = L.ycor() + (paddle_L_move_direction * paddle_move_vert)
R_new_y_pos = R.ycor() + (paddle_R_move_direction * paddle_move_vert)
if paddle_is_allowed_to_move_here (L_new_y_pos):
L.sety( L_new_y_pos )
if paddle_is_allowed_to_move_here (R_new_y_pos):
R.sety( R_new_y_pos )
Game over!
That’s it, we have a working Pong game! I will include the final code below. There are obviously a countless number of ways in which this game can be changed and improved, and I encourage you to do so. You should now also understand how all games fundamentally work, so why not design and program your own?
from turtle import Turtle, Screen, Shape
from random import randint
# SCREEN
screen = Screen()
screen.setup(600, 400) # width, height
screen.tracer(0) # We will handle displaying of frames ourselves
# PLAY AREA
play_top = screen.window_height() / 2 - 100 # top of screen minus 100 units
play_bottom = -screen.window_height() / 2 + 100 # 100 from bottom
play_left = -screen.window_width() / 2 + 50 # 50 from left
play_right = screen.window_width() / 2 - 50 # 50 from right
area = Turtle()
area.hideturtle()
area.penup()
area.goto(play_right, play_top)
area.pendown()
area.goto(play_left, play_top)
area.goto(play_left, play_bottom)
area.goto(play_right, play_bottom)
area.goto(play_right, play_top)
# PADDLES
L = Turtle()
R = Turtle()
L.penup()
R.penup()
# Paddles shape
paddle_w_half = 10 / 2 # 10 units wide
paddle_h_half = 40 / 2 # 40 units high
paddle_shape = Shape("compound")
paddle_points = ((-paddle_h_half, -paddle_w_half),
(-paddle_h_half, paddle_w_half),
(paddle_h_half, paddle_w_half),
(paddle_h_half, -paddle_w_half))
paddle_shape.addcomponent(paddle_points, "black")
screen.register_shape("paddle", paddle_shape)
L.shape("paddle")
R.shape("paddle")
# Move paddles into position
L.setx(play_left + 10)
R.setx(play_right - 10)
paddle_L_move_direction = 0 # L paddle movement direction in next frame
paddle_R_move_direction = 0 # R paddle movement direction in next frame
paddle_move_vert = 4 # Vertical movement distance per frame
def paddle_is_allowed_to_move_here (new_y_pos) :
if (play_bottom > new_y_pos - paddle_h_half) : # bottom of paddle below bottom of field
return False
if (new_y_pos + paddle_h_half > play_top) : # top of paddle above top of field
return False
return True
def update_paddle_positions () :
L_new_y_pos = L.ycor() + (paddle_L_move_direction * paddle_move_vert)
R_new_y_pos = R.ycor() + (paddle_R_move_direction * paddle_move_vert)
if paddle_is_allowed_to_move_here (L_new_y_pos):
L.sety( L_new_y_pos )
if paddle_is_allowed_to_move_here (R_new_y_pos):
R.sety( R_new_y_pos )
def L_up() :
global paddle_L_move_direction
paddle_L_move_direction = 1
def L_down() :
global paddle_L_move_direction
paddle_L_move_direction = -1
def L_off() :
global paddle_L_move_direction
paddle_L_move_direction = 0
def R_up() :
global paddle_R_move_direction
paddle_R_move_direction = 1
def R_down() :
global paddle_R_move_direction
paddle_R_move_direction = -1
def R_off() :
global paddle_R_move_direction
paddle_R_move_direction = 0
screen.onkeypress(L_up, "w")
screen.onkeypress(L_down, "z")
screen.onkeypress(R_up, "Up")
screen.onkeypress(R_down, "Down")
screen.onkeyrelease(L_off, "w")
screen.onkeyrelease(L_off, "z")
screen.onkeyrelease(R_off, "Up")
screen.onkeyrelease(R_off, "Down")
screen.listen()
# SCORE
score_turtle = Turtle()
score_turtle.penup()
score_turtle.hideturtle()
score_L = 0
score_R = 0
def write_scores() :
score_turtle.clear()
score_turtle.goto(-screen.window_width()/4, screen.window_height()/2 - 80)
score_turtle.write(score_L, align="center", font=("Arial", 32, "bold"))
score_turtle.goto(screen.window_width()/4, screen.window_height()/2 - 80)
score_turtle.write(score_R, align="center", font=("Arial", 32, "bold"))
def check_if_someone_scores() :
global score_L, score_R
if (ball.xcor() + ball_radius) >= play_right : # right of ball at right of field
score_L += 1
write_scores()
reset_ball()
elif play_left >= (ball.xcor() - ball_radius) : # left of ball at left of field
score_R += 1
write_scores()
reset_ball()
# BALL
ball = Turtle()
ball.penup()
ball.shape("circle") # Use the built-in shape "circle"
ball.shapesize( 0.5, 0.5) # Stretch it to half default size
ball_radius = 10 * 0.5 # Save the new radius for later
ball_move_horiz = 3 # Horizontal movement per frame
ball_move_vert = 2 # Vertical movement per frame
def ball_collides_with_paddle (paddle) :
x_distance = abs(paddle.xcor() - ball.xcor())
y_distance = abs(paddle.ycor() - ball.ycor())
overlap_horizontally = (ball_radius + paddle_w_half >= x_distance) # either True or False
overlap_vertically = (ball_radius + paddle_h_half >= y_distance) # either True or False
return overlap_horizontally and overlap_vertically # so it returns either True or False
def update_ball_position () :
global ball_move_horiz, ball_move_vert
if ball.ycor() + ball_radius >= play_top : # top of ball at or above top of field
ball_move_vert *= -1
elif play_bottom >= ball.ycor() - ball_radius : # bottom of ball at or below bottom of field
ball_move_vert *= -1
if ball_collides_with_paddle(R) or ball_collides_with_paddle(L) :
ball_move_horiz *= -1
ball.setx(ball.xcor() + ball_move_horiz)
ball.sety(ball.ycor() + ball_move_vert)
def reset_ball() :
global ball_move_vert, ball_move_horiz
ball.setpos(0, 0)
speed_horiz = randint(2,4)
speed_vert = randint(2,4)
direction_horiz = 1
direction_vert = 1
if randint(0,100) > 50 : # 50% chance of going left instead of right
direction_horiz = -1
if randint(0,100) > 50 : # 50% chance of going down instead of up
direction_vert = -1
ball_move_horiz = direction_horiz * speed_horiz
ball_move_vert = direction_vert * speed_vert
# FRAME
def frame () :
check_if_someone_scores()
update_paddle_positions()
update_ball_position()
screen.update() # show the new frame
screen.ontimer(frame, framerate_ms) # schedule this function to be called again a bit later
write_scores()
framerate_ms = 40 # Every how many milliseconds must frame function be called?
frame()