Tennis
Let’s code a simple game of single player tennis inspired by the classic Pong. We’ll code a paddle that we can move up and down the screen with the d-pad or crank. When the ball hits the top, right, and bottom of the screen, it’ll bounce off it. If it goes past our paddle and off the left side of the screen, it’ll be game over. We’ll keep track of how many hits we get. And to add a little difficulty to our game, we’ll make the ball get a little bit faster over time.
Displaying the Paddle
Start off by creating a new folder called tennis with a source folder that contains source/main.lua.
Put the following into source/main.lua:
1 function playdate.update()
2 playdate.graphics.fillRect(36, 80, 12, 48)
3 end
playdate.graphics.fillRect draws a rectangle on the screen and fill it black (the default fill color). The first parameter is the x position, the second is the y position, the third is the width, and the fourth is the height. Our code says to draw a rectangle at 36 pixels in, 80 pixels down, with a width of 12 pixels and a height of 48 pixels.

Let’s make it so we can move the paddle. This won’t be too different from moving text around the screen. But this time let’s use a table to keep track of the paddle position and use key-value pairs to make our data structure more clear.
Lua is quite flexible in that it allows key-less tables, like we saw with the different names, local names = { "Playdate", "Goku", "Bulma", "Piccolo" }. But Lua also allows us to specify keys and values for easily accessing and changing them. We’ll see this in action, but here’s an example of the alternative way of using a table:
1 local player = {
2 name = "Oolong",
3 health = 10,
4 level = 5
5 }
The values of player can be accessed with dot syntax (player.name which returns the string "Oolong") or with bracket syntax (player["health"] which returns the number 10).
Let’s update source/main.lua to introduce a paddle table that has an x and y position:
1 local paddle = {
2 x = 36,
3 y = 80,
4 }
5
6 function playdate.update()
7 playdate.graphics.fillRect(paddle.x, paddle.y, 12, 48)
8 end
We’ve refactored our code to keep track of the paddle’s position in a table named paddle. Very convenient.
Bonus: put the paddle’s width and height into the paddle variable and reference it from the variable when drawing the paddle.
Moving the Paddle
Let’s move the paddle. We’ll start with the d-pad and then support the crank. Much like with moving the text, we’ll check for the up and down input on the d-pad.
Update source/main.lua to be:
1 local paddle = {
2 x = 36,
3 y = 80,
4 s = 10
5 }
6
7 function playdate.update()
8 if playdate.buttonIsPressed(playdate.kButtonUp) then
9 paddle.y -= paddle.s
10 end
11
12 if playdate.buttonIsPressed(playdate.kButtonDown) then
13 paddle.y += paddle.s
14 end
15
16 playdate.graphics.clear()
17 playdate.graphics.fillRect(paddle.x, paddle.y, 12, 48)
18 end
Our expanded code moves the paddle up and down by paddle.s—a new entry in our table that represents the paddle’s speed. By having it live in just one place, it’s easy to change the value to find a speed that feels right. Try changing s around to find a speed that feels good to you.
We also had to make sure we clear the screen every update with playdate.graphics.clear().
Did you notice that the paddle can be moved off the screen? Penalty! Out of bounds! Well, no, not really. But a bad player experience. Let’s make it so that the paddle can’t go outside the bounds of the screen of the Playdate.
We’ll introduce a condition within our checks for buttons being pressed. For up input, we’ll check to see if the paddle’s y position is less than or equal to 0, and if it is, then we’ll set it to 0. For down input, we’ll check to see if the height of the paddle plus its y position is greater than or equal to 240, which is how many pixels tall the Playdate is.
Let’s code that up in source/main.lua:
1 local paddle = {
2 x = 36,
3 y = 80,
4 s = 10,
5 w = 12,
6 h = 48,
7 }
8
9 function playdate.update()
10 if playdate.buttonIsPressed(playdate.kButtonUp) then
11 paddle.y -= paddle.s
12 end
13
14 if playdate.buttonIsPressed(playdate.kButtonDown) then
15 paddle.y += paddle.s
16 end
17
18 if paddle.y <= 0 then
19 paddle.y = 0
20 end
21
22 if paddle.y + paddle.h >= 240 then
23 paddle.y = 240 - paddle.h
24 end
25 playdate.graphics.clear()
26 playdate.graphics.fillRect(paddle.x, paddle.y, paddle.w, paddle.h)
27 end
Now our paddle stops at the top and bottom of the screen. We have to check that the paddle’s y position plus its height is less than the screen height because paddle.y is the top of the rectangle. And the x position is the left side.
There’s an aspect of this code that I don’t love, and it’s 240. When coding, a number that’s present without any context to its meaning is known as a magic number. It’s difficult to know what 240 means at first glance. While we know it’s the height of the Playdate’s screen, it’s not obvious. Our goal is to write code that’s easy to understand. We could make a variable, local screen_height = 240 and reference that. But we’ve got another option that’s a little more futureproof. Playdate provides an API to get the height of the display: playdate.display.getHeight()
1 -- snip
2 local displayHeight = playdate.display.getHeight()
3
4 function playdate.update()
5
6 if playdate.buttonIsPressed(playdate.kButtonUp) then
7 paddle.y -= paddle.s
8 end
9
10 if playdate.buttonIsPressed(playdate.kButtonDown) then
11 paddle.y += paddle.s
12 end
13
14 if paddle.y <= 0 then
15 paddle.y = 0
16 end
17
18 if paddle.y + paddle.h >= displayHeight then
19 paddle.y = displayHeight - paddle.h
20 end
21
22 playdate.graphics.clear()
23 playdate.graphics.fillRect(paddle.x, paddle.y, paddle.w, paddle.h)
24 end
This introduces a variable displayHeight that’s set dynamically from the Playdate SDK and removes the magic number.
Let’s whip the crank out and have turning it move the paddle up and down. Cranking forward will move the paddle down, while cranking backwards will move up. The Playdate SDK provides the playdate.getCrankChange() function, which returns two values: the degrees of angle change since the last time the function was called and an accelerated change value based on how fast the player is moving the crank. We’ll use the second value, the accelerated change, since it feels better.
Add the code to source/main.lua between the d-pad input checking and the boundary checking:
1 -- snip
2 if playdate.buttonIsPressed(playdate.kButtonUp) then
3 paddle.y -= paddle.s
4 end
5
6 if playdate.buttonIsPressed(playdate.kButtonDown) then
7 paddle.y += paddle.s
8 end
9
10 local _crankChange, acceleratedCrankChange = playdate.getCrankChange()
11 paddle.y += acceleratedCrankChange
12
13 if paddle.y <= 0 then
14 paddle.y = 0
15 end
16
17 if paddle.y + paddle.h >= displayHeight then
18 paddle.y = displayHeight - paddle.h
19 end
20 -- snip
The first return value, _crankChange, is prefixed with an underscore to signify that it’s not used. Then we take the acceleratedCrankChange variable and add it to paddle.y. This works because cranking forward returns a positive value and cranking backwards returns a negative value. Just what we need.
(debugging with print to see the crank values)
Bonus #1: swap acceleratedCrankChange for _crankChange when adding to paddle.y and see how that feels.
Bonus #2: how would you increase or decrease the crank change to refine the paddle movement?
Displaying the Ball
Similar to how we drew a rectangle for the paddle, we’ll draw a circle to represent the ball.
At the very top of source/main.lua, we need to import the graphics core library. We’ll also set up a ball table variable for tracking that data.
1 import "CoreLibs/graphics"
2
3 local paddle = {
4 -- snip
5 }
6
7 local ball = {
8 x = 220,
9 y = 80,
10 r = 10,
11 }
Then at the bottom of the playdate.update() function, call playdate.graphics.fillCircleAtPoint:
1 playdate.graphics.clear()
2 playdate.graphics.fillRect(paddle.x, paddle.y, paddle.w, paddle.h)
3 playdate.graphics.fillCircleAtPoint(ball.x, ball.y, ball.r)
r represents the radius of the circle in pixels.
Bonus: Adjust the r radius of the circle to find a size that seems appropriate.
Refactoring playdate.graphics
Let’s take a brief moment to refactor our calls to playdate.graphics to follow the best practices suggested by the Playdate SDK.
We’ll introduce a constant value called gfx to make our code a bit more concise. It also makes our code slightly faster. Win-win!
Update the top of source/main.lua to add the following line below the graphics import:
1 import "CoreLibs/graphics"
2
3 local gfx <const> = playdate.graphics
<const> means that the value is constant. It shouldn’t change nor be reassigned.
Then in playdate.update() replace playdate.graphics with gfx:
1 gfx.clear()
2 gfx.fillRect(paddle.x, paddle.y, paddle.w, paddle.h)
3 gfx.fillCircleAtPoint(ball.x, ball.y, ball.r)
Moving the Ball
We’ve coded two moving objects already—the text from Chapter 1 and the paddle in our tennis game. They responded to user input via the d-pad and crank. The ball in Tennis will move automatically. Instead of checking for player input, we’ll just modify the ball’s position in each update of the game loop.
In source/main.lua below the paddle boundary checking and above the drawing, increase the ball’s x position by 2 pixels every loop:
1 -- snip
2 if paddle.y + paddle.h >= displayHeight then
3 paddle.y = displayHeight - paddle.h
4 end
5
6 ball.x += 2
7
8 gfx.clear()
9 -- snip
Run the game and see what happens. The ball moves to the right. Going, going, gone! It disappears off the screen. It’s still moving, deep into space. We just can’t see it.
Bonus: Refactor the code to put the ball’s speed of 2 into the ball table.
Bounce Off the Wall
Much like how we check for the top and bottom of the screen to stop moving the paddle, we’ll check to see if the ball has hit the right side of the screen. If it has, then we’ll change its direction.
1 -- snip
2
3 local ball = {
4 x = 220,
5 y = 80,
6 r = 10,
7 s = 4,
8 }
9
10 local displayHeight = playdate.display.getHeight()
11 local displayWidth = playdate.display.getWidth()
12
13 function playdate.update()
14 -- snip
15
16 ball.x += ball.s
17
18 if ball.x + ball.r >= displayWidth then
19 ball.x = displayWidth - ball.r
20 ball.s *= -1
21 end
22
23 if ball.x <= ball.r then
24 ball.x = ball.r
25 ball.s *= -1
26 end
27
28 -- snip
29 end
Quite a few changes have been made. First, s was added to the ball table to represent its speed. 2 pixels per update felt too slow, so it’s been increased to 4.
The direction is encoded in the sign of ball.s—positive means move right, negative means move left. While it’s not super clear to combine speed and direction into one variable, that’s okay, it’ll end up going away when we introduce angles.
We set a variable for the displayWidth just like we did for displayHeight but call getWidth() instead. We need it for checking the boundary of the ball.
When we change the ball.x, we use ball.s instead of the magic number from the last section. And we multiply it by -1 to change the direction. *= means multiply the value on the left by the value on the right and assign that new value to it. Multiplying by -1 changes the sign of the direction.
If the ball is moving right, we’re increasing its x value, so ball.s is positive. But if we want the ball to move to the left, we need to subtract ball.s from ball.x. By multiplying ball.s by -1, it subtracts 4 from ball.x in future loops. To make the ball move right again, multiply it by -1 to turn it positive. This directional modifier is quite powerful and a regular staple of game programming.
Then we check if the ball’s x position plus its radius (r) are greater than or equal to the width of the screen. If it is, we set the ball’s position to the furthest right edge and change the direction to -1 so it moves to the left in the next game loop call of playdate.update().
Similarly but for the left side of the screen, we check if the ball.x is less than or equal to the ball’s radius (r). If it is, we set the ball.x to the ball.r and change the direction to move to the right.
The boundary checking for the ball is slightly different than our paddle as the origin point of the circle is in the center, not the upper left. This means we need to factor this into our logic. Try changing if ball.x <= ball.r then to if ball.x <= 0 then and see what happens. (And then undo it because it’s not what we want moving forward).
While it’s fun to watch the ball move back and forth, it flies right through our paddle. Let’s make it so that when the ball overlaps with the paddle, it changes direction.
Bounce Off the Paddle
To bounce the ball off of the paddle, we’ll check to see if the circular ball overlaps the rectangular paddle. This is where we introduce non-trivial math into our game in the form of trigonometry. Don’t worry if you don’t know much trig, I’ll break it down and explain it.
We’ll also introduce our first custom function to encapsulate the logic. Our playdate.update() function is getting a bit long and unwieldy, so by putting code into functions, we can keep it clear and focused.
1 -- snip
2 function playdate.update()
3 -- snip
4
5 ball.x += ball.s
6
7 if circleOverlapsRect(ball, paddle) then
8 ball.s *= -1
9 end
10
11 -- snip
12 end
13
14 function circleOverlapsRect(circle, rect)
15 -- Find the closest point in the rectangle to the circle's center
16 local closestX = math.max(rect.x, math.min(circle.x, rect.x + rect.w))
17 local closestY = math.max(rect.y, math.min(circle.y, rect.y + rect.h))
18
19 -- Calculate the distance between the circle's center and the closest point
20 local distance = math.sqrt((closestX - circle.x)^2 + (closestY - circle.y)^2)
21
22 -- If the distance is less than or equal to the radius, there's overlap
23 return distance <= circle.r
24 end
Right below where we update the ball’s x position we’ll add a conditional check with the new function we’re adding: circleOverlapsRect. We pass in the ball (our circle) as the first parameter and then the paddle (our rectangle) as the second parameter. If the circle does overlap the rectangle, meaning the ball hit our paddle, we’ll flip the ball’s direction by changing the sign of its speed.
The circleOverlapsRect function takes a circle and rect as arguments. There are some comments that start with -- to explain the code a bit.
Let’s breakdown the determination of closestX and closestY. It checks to see what point within the rect is closest to the circle by comparing the center of the circle to the bounding box of the rect.
The distance formula is then used to determine the distance between the closest point in the rect and the center of the circle. If the distance is less than or equal to the circle’s radius (circle.r), then it means the circle is overlapping the rectangle.
Refactor into Functions
Our code in playdate.update() is getting complex and unwieldy. Let’s refactor the code we’ve got into separate functions that we call from within playdate.update(). Putting code into functions serves three key purposes:
- Functions help us reuse code rather than repeating it multiple times.
- Functions break our code down into smaller parts which are easier to understand.
- Functions self-document our code by grouping lines together into something with a name.
Our current source/main.lua looks like this:
1 import "CoreLibs/graphics"
2
3 local gfx <const> = playdate.graphics
4
5 local paddle = {
6 x = 36,
7 y = 80,
8 s = 10,
9 w = 12,
10 h = 48,
11 }
12
13 local ball = {
14 x = 220,
15 y = 80,
16 r = 10,
17 s = 6,
18 }
19
20 local displayHeight = playdate.display.getHeight()
21 local displayWidth = playdate.display.getWidth()
22
23 function playdate.update()
24 if playdate.buttonIsPressed(playdate.kButtonUp) then
25 paddle.y -= paddle.s
26 end
27
28 if playdate.buttonIsPressed(playdate.kButtonDown) then
29 paddle.y += paddle.s
30 end
31
32 local _crankChange, acceleratedCrankChange = playdate.getCrankChange()
33 paddle.y += acceleratedCrankChange
34
35 if paddle.y <= 0 then
36 paddle.y = 0
37 end
38
39 if paddle.y + paddle.h >= displayHeight then
40 paddle.y = displayHeight - paddle.h
41 end
42
43 ball.x += ball.s
44
45 if ball.x + ball.r >= displayWidth then
46 ball.x = displayWidth - ball.r
47 ball.s *= -1
48 end
49
50 if ball.x <= ball.r then
51 ball.x = ball.r
52 ball.s *= -1
53 end
54
55 if circleOverlapsRect(ball, paddle) then
56 ball.s *= -1
57 end
58
59 gfx.clear()
60 gfx.fillRect(paddle.x, paddle.y, paddle.w, paddle.h)
61 gfx.fillCircleAtPoint(ball.x, ball.y, ball.r)
62 end
63
64 function circleOverlapsRect(circle, rect)
65 -- Find the point to the circle center within the rectangle
66 local closestX = math.max(rect.x, math.min(circle.x, rect.x + rect.w))
67 local closestY = math.max(rect.y, math.min(circle.y, rect.y + rect.h))
68
69 -- Distance between the circle center and the closest point
70 local distance = math.sqrt((closestX - circle.x)^2 + (closestY - circle.y)^2)
71
72 -- If the distance is less than or equal to the radius, there's overlap
73 return distance <= circle.r
74 end
I see three functions we can extract:
- Moving the paddle:
movePaddle() - Moving the ball:
moveBall() - Drawing our shapes:
draw()
Here’s what our refactored code looks like:
1 import "CoreLibs/graphics"
2
3 local gfx <const> = playdate.graphics
4
5 local paddle = {
6 x = 36,
7 y = 80,
8 s = 10,
9 w = 12,
10 h = 48,
11 }
12
13 local ball = {
14 x = 220,
15 y = 80,
16 r = 10,
17 s = 6,
18 }
19
20 local displayHeight = playdate.display.getHeight()
21 local displayWidth = playdate.display.getWidth()
22
23 function playdate.update()
24 movePaddle()
25 moveBall()
26
27 if circleOverlapsRect(ball, paddle) then
28 ball.s *= -1
29 end
30
31 draw()
32 end
33
34 function draw()
35 gfx.clear()
36 gfx.fillRect(paddle.x, paddle.y, paddle.w, paddle.h)
37 gfx.fillCircleAtPoint(ball.x, ball.y, ball.r)
38 end
39
40 function movePaddle()
41 if playdate.buttonIsPressed(playdate.kButtonUp) then
42 paddle.y -= paddle.s
43 end
44
45 if playdate.buttonIsPressed(playdate.kButtonDown) then
46 paddle.y += paddle.s
47 end
48
49 local _crankChange, acceleratedCrankChange = playdate.getCrankChange()
50 paddle.y += acceleratedCrankChange
51
52 if paddle.y <= 0 then
53 paddle.y = 0
54 end
55
56 if paddle.y + paddle.h >= displayHeight then
57 paddle.y = displayHeight - paddle.h
58 end
59 end
60
61 function moveBall()
62 ball.x += ball.s
63
64 if ball.x + ball.r >= displayWidth then
65 ball.x = displayWidth - ball.r
66 ball.s *= -1
67 end
68
69 if ball.x <= ball.r then
70 ball.x = ball.r
71 ball.s *= -1
72 end
73 end
74
75 function circleOverlapsRect(circle, rect)
76 -- Find the point to the circle center within the rectangle
77 local closestX = math.max(rect.x, math.min(circle.x, rect.x + rect.w))
78 local closestY = math.max(rect.y, math.min(circle.y, rect.y + rect.h))
79
80 -- Distance between the circle center and the closest point
81 local distance = math.sqrt((closestX - circle.x)^2 + (closestY - circle.y)^2)
82
83 -- If the distance is less than or equal to the radius, there's overlap
84 return distance <= circle.r
85 end
All we’ve done is move our code into separate functions and then call them within playdate.update(). It’s a lot easier to reason about and refer to the code.
The call to the circleOverlapsRect function did not get moved into its own function because it’s simple enough to just stay put. It wouldn’t fit naturally into movePaddle() or moveBall() since it deals with both of them. If collision detection gets more complicated, then it might make sense to move it into its own function.
Now that our code is cleaned up a bit, let’s make the game more exciting. Let’s introduce angles to make the ball bounce all over the screen.
Angles
Our ball is just moving along the x-axis, which isn’t very exciting. We want to make our ball move all over the screen. This means, yes, more math! We need to keep track of the angle of our ball and use that in conjunction with our speed to determine its velocity.
1 -- snip
2
3 local ball = {
4 x = 220,
5 y = 80,
6 r = 10,
7 s = 6,
8 a = 195, -- degrees
9 }
10
11 -- snip
12
13 function playdate.update()
14 movePaddle()
15 moveBall()
16
17 if circleOverlapsRect(ball, paddle) then
18 ball.a = math.random(160, 200) - ball.a
19 end
20
21 draw()
22 end
23
24 function draw()
25 -- snip
26 end
27
28 function movePaddle()
29 -- snip
30 end
31
32 function moveBall()
33 local radians = math.rad(ball.a)
34 ball.x += math.cos(radians) * ball.s
35 ball.y += math.sin(radians) * ball.s
36
37 if ball.x + ball.r >= displayWidth then
38 ball.x = displayWidth - ball.r
39 ball.a = 180 - ball.a
40 end
41
42 if ball.x <= ball.r then
43 ball.x = ball.r
44 ball.a = 180 - ball.a
45 end
46
47 if ball.y + ball.r >= displayHeight then
48 ball.y = displayHeight - ball.r
49 ball.a *= -1
50 end
51
52 if ball.y <= ball.r then
53 ball.y = ball.r
54 ball.a *= -1
55 end
56 end
57
58 function circleOverlapsRect(circle, rect)
59 -- snip
60 end
Play your game on the Playdate Simulator and upload to your Playdate to play test it if you haven’t yet. It’s starting to get fun!
There are some major changes in the code with how we’re handling the ball movement.
First, we introduce ball.a to represent the ball’s angle of movement. It’s in degrees and initially set to 195, which is to the right and down a little bit. (180 would be straight to the right.) We’ll use the angle when determining how to change the x and y position of the ball based on its speed.
In playdate.update() we set the ball.a to a random value between 160 and 200 degrees minus ball.a. This turns the ball around and gives it a little bit of random variance to prevent the ball from getting deadlocked in a straight line.
draw(), movePaddle(), and circleOverlapsRect() remain unchanged. But moveBall() gets entirely overhauled.
Let’s break down the two key parts:
1 local radians = math.rad(ball.a)
2 ball.x += math.cos(radians) * ball.s
3 ball.y += math.sin(radians) * ball.s
Since the ball is moving at an angle, both its x and y position need to change based on that angle. More trigonometry!
We convert the ball’s angle to radians, which we need for working with the sine (math.sin()) and cosine (math.cos()) functions. We calculate the change in the x position based on the cosine of the angle times the ball’s speed. The y position is similar but we use sine instead. The foundation of this formula is using the unit circle to determine angular velocity.
After we change the x and y position, we check to see if the ball has collided with the top, bottom, left, and right side of the screen. For the left and right side along the x axis, we subtract the current ball.a from 180 to flip the horizontal movement. For the top and bottom collisions along the y axis, we multiply the ball.a by -1 to keep the horizontal direction the same but change the vertical direction.
Let’s make it so the ball gets faster each time it hits the paddle. This will make the game more challenging the better we get at it.
Speeding Up the Ball
To make the ball go faster, all we need to do is increase ball.s in our if circleOverlapsRect(ball, paddle) then conditional check in playdate.update().
1 -- snip
2
3 if circleOverlapsRect(ball, paddle) then
4 ball.a = math.random(160, 200) - ball.a
5 ball.s += 1
6 end
7
8 -- snip
It quickly gets out of hand and the ball moves way too fast. So let’s set a max upper speed by adding max_s to our ball table and checking to see if ball.s is beyond it.
1 -- snip
2
3 local ball = {
4 x = 220,
5 y = 80,
6 r = 10,
7 s = 6,
8 max_s = 12,
9 a = 195, -- degrees
10 }
11
12 -- snip
13
14 function playdate.update()
15 movePaddle()
16 moveBall()
17
18 if circleOverlapsRect(ball, paddle) then
19 ball.a = math.random(160, 200) - ball.a
20
21 if ball.s < ball.max_s then
22 ball.s += 1
23 end
24 end
25
26 draw()
27 end
28
29 -- snip
There’s something really fun about using the crank to hit the ball around! We’re starting to get our first glimpses at the fun and joy of making games.
Score
Let’s keep track of the score for each round and reset it to zero if the ball hits the left wall. We’ll also reset the ball to its initial position, speed, and angle when the left wall is hit. We need a new variable, score, that we’ll set and draw on the screen. We’ll also keep track of the initial values we want to track for resetting the game.
1 -- snip
2
3 local ball = {
4 x = 220,
5 y = 80,
6 r = 10,
7 s = 6,
8 max_s = 12,
9 a = 195, -- degrees
10 }
11
12 ball.initX = ball.x
13 ball.initY = ball.y
14 ball.initS = ball.s
15 ball.initA = ball.a
16
17 local score = 0
18
19 local displayHeight = playdate.display.getHeight()
20 local displayWidth = playdate.display.getWidth()
21
22 function playdate.update()
23 movePaddle()
24 moveBall()
25
26 if circleOverlapsRect(ball, paddle) then
27 ball.a = math.random(160, 200) - ball.a
28
29 score += 1
30
31 if ball.s < ball.max_s then
32 ball.s += 1
33 end
34 end
35
36 draw()
37 end
38
39 function draw()
40 -- snip
41 gfx.drawText("Score: " .. score, displayWidth - 100, 20)
42 end
43
44 function movePaddle()
45 -- snip
46 end
47
48 function moveBall()
49 -- snip
50
51 if ball.x <= ball.r then
52 resetGame()
53 end
54
55 -- snip
56 end
57
58 function resetGame()
59 score = 0
60 ball.x = ball.initX
61 ball.y = ball.initY
62 ball.s = ball.initS
63 ball.a = ball.initA
64 end
65
66 -- snip
There’s nothing too new here. Lots of using the fundamentals we already learned. Except for the assigning of ball.initX, ball.initY, etc. Basically what the code does is that after we create the ball, we take the values and assign them to a separate entry in the table. Because ball.x, ball.y, etc. are going to change throughout the game. We want to be able to easily reset to those initial values.
We’re almost done with Tennis. Let’s polish it up a little bit more.
Sound Effects
Our game is feeling a bit quiet, don’t you think? The Playdate SDK offers a lot of options for playing sound effects, from samples to files to an included synth. We’ll add some simple MIDI sound effects to our game whenever the ball hits anything.
1 -- snip
2 local synth = playdate.sound.synth.new(playdate.sound.kWaveSine)
3
4 function playdate.update()
5 movePaddle()
6 moveBall()
7
8 if circleOverlapsRect(ball, paddle) then
9 ball.a = math.random(160, 200) - ball.a
10 playSFX("C4")
11
12 score += 1
13
14 -- snip
15 end
16
17 draw()
18 end
19
20 function draw()
21 -- snip
22 end
23
24 function movePaddle()
25 -- snip
26 end
27
28 function moveBall()
29 -- snip
30
31 if ball.x + ball.r >= displayWidth then
32 playSFX("E4")
33 ball.x = displayWidth - ball.r
34 ball.a = 180 - ball.a
35 end
36
37 if ball.x <= ball.r then
38 playSFX("F3")
39 resetGame()
40 end
41
42 if ball.y + ball.r >= displayHeight then
43 playSFX("D4")
44 ball.y = displayHeight - ball.r
45 ball.a *= -1
46 end
47
48 if ball.y <= ball.r then
49 playSFX("A4")
50 ball.y = ball.r
51 ball.a *= -1
52 end
53 end
54
55 function resetGame()
56 -- snip
57 end
58
59 function playSFX(note)
60 synth:playMIDINote(note, 1, 0.25)
61 end
62
63 -- snip
We create a new synth variable using the kWaveSine constant. And we use that in the new playSFX function, which takes a note and plays it on the synth for a quarter of a second. The second parameter, 1, is the volume of the sound effect. You can see that for each wall the ball hits, we play a different note. This is more pleasing than repeating the same sound over and over. And the sound when the ball hits the paddle is also different.
Bonus: Similar to how we greeted a random name in the first chapter, create a table of music notes and randomly select one when the ball hits the paddle.
Final Code
That’s Tennis! Our version is complete, at least for now. We learned a whole lot in this chapter. We wrote our own functions, drew shapes, implemented angular velocity, created and changed a whole bunch of variables, and made something that’s a little fun. Nice work.
Here’s the finished code for this chapter:
1 import "CoreLibs/graphics"
2
3 local gfx <const> = playdate.graphics
4
5 local paddle = {
6 x = 36,
7 y = 80,
8 s = 10,
9 w = 12,
10 h = 48,
11 }
12
13 local ball = {
14 x = 220,
15 y = 80,
16 r = 10,
17 s = 6,
18 max_s = 12,
19 a = 195, -- degrees
20 }
21
22 ball.initX = ball.x
23 ball.initY = ball.y
24 ball.initS = ball.s
25 ball.initA = ball.a
26
27 local score = 0
28
29 local displayHeight = playdate.display.getHeight()
30 local displayWidth = playdate.display.getWidth()
31 local synth = playdate.sound.synth.new(playdate.sound.kWaveSine)
32
33 function playdate.update()
34 movePaddle()
35 moveBall()
36
37 if circleOverlapsRect(ball, paddle) then
38 ball.a = math.random(160, 200) - ball.a
39 playSFX("C4")
40
41 score += 1
42
43 if ball.s < ball.max_s then
44 ball.s += 1
45 end
46 end
47
48 draw()
49 end
50
51 function draw()
52 gfx.clear()
53 gfx.fillRect(paddle.x, paddle.y, paddle.w, paddle.h)
54 gfx.fillCircleAtPoint(ball.x, ball.y, ball.r)
55 gfx.drawText("Score: " .. score, displayWidth - 100, 20)
56 end
57
58 function movePaddle()
59 if playdate.buttonIsPressed(playdate.kButtonUp) then
60 paddle.y -= paddle.s
61 end
62
63 if playdate.buttonIsPressed(playdate.kButtonDown) then
64 paddle.y += paddle.s
65 end
66
67 local _crankChange, acceleratedCrankChange = playdate.getCrankChange()
68 paddle.y += acceleratedCrankChange
69
70 if paddle.y <= 0 then
71 paddle.y = 0
72 end
73
74 if paddle.y + paddle.h >= displayHeight then
75 paddle.y = displayHeight - paddle.h
76 end
77 end
78
79 function moveBall()
80 local radians = math.rad(ball.a)
81 ball.x += math.cos(radians) * ball.s
82 ball.y += math.sin(radians) * ball.s
83
84 if ball.x + ball.r >= displayWidth then
85 playSFX("E4")
86 ball.x = displayWidth - ball.r
87 ball.a = 180 - ball.a
88 end
89
90 if ball.x <= ball.r then
91 playSFX("F3")
92 resetGame()
93 end
94
95 if ball.y + ball.r >= displayHeight then
96 playSFX("D4")
97 ball.y = displayHeight - ball.r
98 ball.a *= -1
99 end
100
101 if ball.y <= ball.r then
102 playSFX("A4")
103 ball.y = ball.r
104 ball.a *= -1
105 end
106 end
107
108 function resetGame()
109 score = 0
110 ball.x = ball.initX
111 ball.y = ball.initY
112 ball.s = ball.initS
113 ball.a = ball.initA
114 end
115
116 function playSFX(note)
117 synth:playMIDINote(note, 1, 0.25)
118 end
119
120 function circleOverlapsRect(circle, rect)
121 -- Find the point to the circle center within the rectangle
122 local closestX = math.max(rect.x, math.min(circle.x, rect.x + rect.w))
123 local closestY = math.max(rect.y, math.min(circle.y, rect.y + rect.h))
124
125 -- Distance between the circle center and the closest point
126 local distance = math.sqrt((closestX - circle.x)^2 + (closestY - circle.y)^2)
127
128 -- If the distance is less than or equal to the radius, there's overlap
129 return distance <= circle.r
130 end
130 lines of code. That’s respectable for our first Playdate game. If you’ve got someone you can share your Playdate with, show them what you made!
Extra Credits
There’s a lot you could do to expand Tennis into something more fun. Here are some ideas to take it to the next level:
- Make the game two player by introducing another paddle. Player 1 controls the left paddle with the d-pad and Player 2 controls the right paddle with the crank.
- Add multiple balls that spawn after reaching certain score thresholds!
- Add collectibles to aim for or even bricks to break.
- Add pinball-style bumpers that the ball can hit.
- Make the ball appear larger when it hits the paddle or a wall.
- Shake the paddle when it’s hit by the ball.
- There’s a small bug—sometimes the ball and paddle can get stuck on each other. How would you separate them when they collide based on the position of the ball when it hits the paddle?
- In future chapters we’ll learn about saving data. Make it so that the high score is saved between play sessions. When a new high-score is reached, modify the score text to let the player know!
- Adding a mute setting would be nice!
- Rework the paddle’s movement to use acceleration and factor its current velocity into the angle the ball is bounced off at (e.g., if the paddle hits the ball while the paddle is moving up, the ball should also move up and to the right).
What’s Next
Tennis was a lot to soak in. Let’s take a breather by coding up a simple little utility for our Playdate: a clock.