This post has been ported from the old blog and slightly edited on the 13th of May 2022.
Making games is one of the coolest things you can do with self-taught programming skills. You have complete creative freedom, you don’t have to have a unique and marketable idea and you’re not potentially putting anyone in danger by messing up.
While there are a lot of great game engines out there (my favorite being Godot), I recently came across an awesome project on GitHub named Pyxel. It is a game engine aiming to enable a quick and easy way to develop games in a retro style.
I have played around with it a little and I was having such a good time playing around with it, that I thought I would write up a small little tutorial about how I coded a quick version of “Pong” in just shy of 130 lines of Python.
What we’re building
So this version of Pong is a single-player version where one player controls both bats and gets a point every time they hit the ball. Easy enough? Let’s do it.
(Screenshot lost during porting, trying to recover.)
Setting up the basics
import pyxel
SCREEN_WIDTH = 255
SCREEN_HEIGHT = 120
class App:
def __init__(self):
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT)
pyxel.run(self.update, self.draw)
App()
So here we are just wrapping our app in its own class, using Pyxel’s built-in methods to initialize the screen and run the game.
Pyxel’s run()
-method takes in two functions as arguments, one that will update the game before every frame and one that will redraw the screen after the changes have been calculated, which I have named accordingly.
So let’s write those methods inside the App
:
def update(self):
if pyxel.btnp(pyxel.KEY_Q):
pyxel.quit()
def draw(self):
pyxel.cls(0)
Here the update()
-method does nothing more - for now -, but to listen for a button press of the “Q”-key and quit the program, when it receives True
. The draw()
-method uses the built-in cls()
-method to clear the screen using the color passed to it (in this case, 0 represents black, Pyxel exposes an enumerated color palette of 16 colors you can view in their docs).
Hooray! If we run this script, we should get a black window that does absolutely nothing. Not that exciting, however, you can check if everything is put together correctly by pressing the “Q”-key. If the window closes, everything works as expected.
Balling
Arguably the most important game object in Pong is the ball. Let’s write a class for our ball.
class Ball:
def __init__(self, position, velocity):
self.position = position
self.velocity = velocity
The ball doesn’t really need any other properties than a position to define where it is and a velocity to define where it is going. So far so good. Let’s make sure our App
knows about our Ball
: For this purpose we are going to initialize an instance of the Ball
, right when the App
loads, like so:
def __init__(self):
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT)
self.ball = Ball(PLACEHOLDER_POSITION, PLACEHOLDER_VELOCITY)
pyxel.run(self.update, self.draw)
and in the draw()
method, we will make sure a circle is drawn to represent the ball:
def draw(self):
pyxel.cls(0)
pyxel.circ(self.ball.position, 2, 7)
Here, 2 is the radius of the circle (aka our Ball
) and 7 is the color in which the ball is drawn. Hold on though, Pyxel’s circ()
method needs two positional inputs, one for the x-axis (horizontal) and one for the y-axis (vertical). Let’s get onto that.
Position and Velocity
On a two-dimensional playing field, you will generally need two values to define the position of an object, one for each dimension (axis).
This is also true for the velocity, you need two values to know in which direction the object is moving, however, the fact that you probably also want to keep control over the speed of the object, makes this a little trickier, so let’s focus on the position for now.
Position
Since we will have to manage the position of multiple objects in this project, it makes sense to write a class for those 2D vectors:
class Vec2:
def __init__(self, x, y):
self.x = x
self.y = y
Easy enough. Let’s apply this to our Ball
:
class Ball:
def __init__(self, px, py, vx, vy):
self.position = Vec2(px, py)
self.velocity = Vec2(vx, vy)
Now, our Ball gets initialized with four values: an x
and a y
value for each the position and the velocity. Those values are then swiftly turned into Vec2
s, so we can easily access the values, i.e. via any_ball.position.x
. Let’s make use of that in our App
’s draw()
method:
def draw(self):
pyxel.cls(0)
pyxel.circ(
self.ball.position.x,
self.ball.position.y,
2,
7
)
Now let’s properly initiate the Ball
in our App
by changing the line self.ball = Ball(PLACEHOLDER_POSITION, PLACEHOLDER_VELOCITY)
from using the placeholders to something like self.ball = Ball(20, 20, 2, 2)
. When you run the script now, you should see your ball, standing there, proudly, 20 pixels from the left and 20 pixels from the top border of the window. It won’t move though, since we haven’t told the Ball
what to do with its velocity values yet.
Velocity
Now we should give our Ball
class its own update
() `method to make sure it knows what to do with those velocity values:
def update(self):
self.position.x += self.velocity.x
self.position.y += self.velocity.y
We now have to call this update()
method within our App
’s own update method, otherwise, it won’t be called at every frame. So add the line self.ball.update()
there (but outside the scope of our existing if-statement). We’re not done though. This will run our ball off the screen, never to be seen again (feel free to try it out). Let’s constrain our ball’s movement by adding two simple rules to the update()
-method:
if self.position.y >= SCREEN_HEIGHT - 2:
self.velocity.y = -self.velocity.y
if self.position.y <= 2:
self.velocity.y = -self.velocity.y
This makes sure that when the ball hits either the top or the bottom border of the screen, it will change its direction on the y-axis. The number 2 here represents the size of the ball and since this is a value now that we are using repeatedly, we should store it in a variable with something like BALL_SIZE = 2
.
We could also add similar rules for the left and right border here, but since touching the left or right border should later end the game, we can omit this here.
At this point, our Ball
-class looks like this:
class Ball:
def __init__(self, px, py, vx, vy):
self.position = Vec2(px, py)
self.velocity = Vec2(vx, vy)
def update(self):
self.position.x += self.velocity.x
self.position.y += self.velocity.y
if self.position.y >= SCREEN_HEIGHT - BALL_SIZE:
self.velocity.y = -self.velocity.y
if self.position.y <= BALL_SIZE:
self.velocity.y = -self.velocity.y
(Don’t forget to update the App
’s draw()
method to use the newly created BALL_SIZE
constant as well.)
As of right now, we have no control over the ball’s speed other than indirectly via the values we pass at the time of the initialization. This is a problem for two reasons: 1) The speed of the ball will vary based on its angle (I’m not going to go into detail here, but if you want to try it out, you can take the script we have written so far and make some more balls with different velocity values and watch how they behave). 2) If we were to change the speed of the ball (to make it harder as the game goes along for example), we couldn’t easily do so.
To solve this problem, we need to ‘normalize’ the vector, which means that you figure out a vector’s length, and reduce it to 1. With a vector always having the same length, regardless of its angle, you can then reliably control its speed.
We could include something like a normalize() method in our existing Vec2 class, but for our purposes, I think it’s a better solution to just write another class for normalized 2D vectors. A class that does everything we just discussed would look like this:
class Vec2_norm:
def __init__(self, x, y):
self.magnitude = math.sqrt(x * x + y * y) # this is how you get the magnitude (length) of a vector
self.x = x / self.magnitude * BALL_SPEED
self.y = y / self.magnitude * BALL_SPEED
For this to work we need to do three things: import math
at the top of the script, update our Ball
’s __init__()
method to use the new class like so: self.velocity = Vec2_norm(vx, vy)
and create a constant variable BALL_SPEED = 2
.
Whew. That was a lot. If you need a break, this would be a great point to take one. Just so we’re on the same page, here is the full script we are having so far:
import math
import pyxel
BALL_SIZE = 2
BALL_SPEED = 2
SCREEN_WIDTH = 255
SCREEN_HEIGHT = 120
class Vec2:
def __init__(self, x, y):
self.x = x
self.y = y
class Vec2_norm:
def __init__(self, x, y):
self.magnitude = math.sqrt(x * x + y * y)
self.x = x / self.magnitude * BALL_SPEED
self.y = y / self.magnitude * BALL_SPEED
class Ball:
def __init__(self, px, py, vx, vy):
self.position = Vec2(px, py)
self.velocity = Vec2_norm(vx, vy)
def update(self):
self.position.x += self.velocity.x
self.position.y += self.velocity.y
if self.position.y >= SCREEN_HEIGHT - BALL_SIZE:
self.velocity.y = -self.velocity.y
if self.position.y <= BALL_SIZE:
self.velocity.y = -self.velocity.y
class App:
def __init__(self):
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT)
self.ball = Ball(20, 20, 2, 2)
pyxel.run(self.update, self.draw)
def update(self):
if pyxel.btnp(pyxel.KEY_Q):
pyxel.quit()
self.ball.update()
def draw(self):
pyxel.cls(0)
pyxel.circ(
self.ball.position.x,
self.ball.position.y,
BALL_SIZE,
7
)
App()
Batting
Did you run it, did it work? Cool. Now we’re still missing a crucial part of the game, which is the bats. So let’s implement the steps for the bats, what we already know how to do:
- Write a class
class Bat:
def __init__(self, px, py):
self.position = Vec2(px, py)
self.velocity = 0
We don’t need a vector for the velocity in this case, since the bats will only be moving on one axis and we can also just set it to 0 right away since the bats shouldn’t be moving when the game starts.
- Let’s instantiate two bricks with positions on the left and on the right side of the screen in our App, right when it loads:
def __init__(self):
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT)
self.ball = Ball(20, 20, 2, 2)
self.bats = [Bat(10, 10), Bat(SCREEN_WIDTH - 10, 10)]
pyxel.run(self.update, self.draw)
- Let’s draw a rectangle shape in our
App
’sdraw()
method for our bats. Let’s also apply what we have learned when making theBall
and set a variable forBAT_SIZE = 8
right away.
def draw(self):
pyxel.cls(0)
pyxel.circ(
self.ball.position.x,
self.ball.position.y,
BALL_SIZE,
7
)
for bat in self.bats:
pyxel.rect(
bat.position.x - BAT_SIZE / 4, # x-coordinate of top left corner
bat.position.y - BAT_SIZE, # y-coordinate of top left corner
bat.position.x + BAT_SIZE / 4, # x-coordinate of bottom right corner
bat.position.y + BAT_SIZE, # y-coordinate of bottom right corner
7 # fill color
)
- In anticipation that this is what we are writing next, let’s call our bats’
update()
method inside theApp
’s ownupdate()
method as we did for the ball.
def update(self):
if pyxel.btnp(pyxel.KEY_Q):
pyxel.quit()
self.ball.update()
for bat in self.bats:
bat.update()
Okay, so now let’s get to that update()
method itself.
First, we need to tell it (like the Ball), what to do with its velocity value, secondly, we want to tell it to change its velocity on button-press
if pyxel.btnp(pyxel.KEY_W):
self.velocity = -2
if pyxel.btnp(pyxel.KEY_S):
self.velocity = 2
and third, we want the bats to stop when they hit the top or bottom edge of the screen.
if self.position.y - BAT_SIZE < 0:
self.position.y = BAT_SIZE
self.velocity = 0
if self.position.y + BAT_SIZE > SCREEN_HEIGHT:
self.position.y = SCREEN_HEIGHT - BAT_SIZE
self.velocity = 0
If you run the script now, all the pieces are in place, but when we try to hit the ball with the bat, it just passes right through. That’s obviously not what we want. If we were using a more sophisticated game engine, we’d do something like drawing a ‘hitbox’ around our game objects, or we would make the objects rigid bodies, something of this sort. In Pyxel though, we have to implement this behavior ourselves. Let’s hold on for a second and think about this thoroughly, remember: think twice, code once.
Hitting on it
Okay, so the hitbox is a property of the bats and it should correlate with the drawn rectangle. So it would make sense to have a hitbox attribute in the `Bat` class and then draw that hitbox rather than arbitrary values as we are doing right now.Also, since the hitbox will be a collection of various values, it would be a good idea to write its own class, nothing fancy, something like:
class HitBox:
def __init__(self, x1, y1, x2, y2):
self.x1 = x1 # x-coordinate of top left corner
self.y1 = y1 # y-coordinate of top left corner
self.x2 = x2 # x-coordinate of bottom right corner
self.y2 = y2 # y-coordinate of bottom right corner
Let’s take a look inside our App
’s draw()
method:
for bat in self.bats:
pyxel.rect(
bat.position.x - BAT_SIZE / 4, # x-coordinate of top left corner
bat.position.y - BAT_SIZE, # y-coordinate of top left corner
bat.position.x + BAT_SIZE / 4, # x-coordinate of bottom right corner
bat.position.y + BAT_SIZE, # y-coordinate of bottom right corner
7 # fill color
)
Let’s cut those calculations and rather use them in our Bat
class to instantiate a hitbox:
class Bat:
def __init__(self, px, py):
self.position = Vec2(px, py)
self.velocity = 0
self.hitBox = HitBox(
self.position.x - BAT_SIZE / 4,
self.position.y - BAT_SIZE,
self.position.x + BAT_SIZE / 4,
self.position.y + BAT_SIZE
)
This allows us to use the hitboxes of our bats to draw the rectangles, again in our App
’s draw()
method, we can simply write:
for bat in self.bats:
pyxel.rect(
bat.hitBox.x1,
bat.hitBox.y1,
bat.hitBox.x2,
bat.hitBox.y2,
7
)
It might not seem like a huge deal, but this actually means that if we calculate whether or not the ball has been hit by the bat, this will always align with what the user sees on their screen, which is kind of important for obvious reasons.
Before we continue, we have to make sure the hitbox also updates every frame, so we have to update the Bat
’s update()
method:
def update(self):
self.position.y += self.velocity
self.hitBox = HitBox(
self.position.x - BAT_SIZE / 4,
self.position.y - BAT_SIZE,
self.position.x + BAT_SIZE / 4,
self.position.y + BAT_SIZE
)
Great, so let’s move over and write a simple conditional statement that checks whether or not the position of the ball is inside the bat’s hitbox, and if it is, we want the ball to reverse it’s velocity on the x-axis. This is what I came up with:
for bat in self.bats:
bat.update()
if (bat.hitBox.x1 < self.ball.position.x < bat.hitBox.x2
and bat.hitBox.y1 < self.ball.position.y < bat.hitBox.y2):
self.ball.velocity.x = -self.ball.velocity.x
(If you spot a problem with this right away, you would be right, if not, I’m revisiting this in the last segment as an opportunity to debug the script, for now though, this does what it’s supposed to do.)
Scoring and losing
The game works! Let’s implement the score and the loss condition: For the loss condition, we just check for the ball’s position on the x-axis and if it’s below 0 or farther right than the screen width, the game is over. For the score, we initiate the App
with a score
attribute of 0. Now every time the ball hits a bat, we increment the score by 1. After those small changes, the App
’s update()
method should look like this:
def update(self):
if pyxel.btnp(pyxel.KEY_Q):
pyxel.quit()
self.ball.update()
for bat in self.bats:
bat.update()
if (bat.hitBox.x1 < self.ball.position.x < bat.hitBox.x2
and bat.hitBox.y1 < self.ball.position.y < bat.hitBox.y2):
self.ball.velocity.x = -self.ball.velocity.x
self.score += 1
if self.ball.position.x >= SCREEN_WIDTH - BALL_SIZE:
pyxel.quit()
if self.ball.position.x <= BALL_SIZE:
pyxel.quit()
Finally, let’s give the user some feedback on his score, by including the score as text on the screen. Inside the App
’s draw()
method, let’s insert:
pyxel.text(
SCREEN_WIDTH / 2, # x-position of the text
SCREEN_HEIGHT / 12, # y position of the text
str(self.score), # displayed text as string
7 # text color
)
And that’s it! The game is working and is eager to be played. In case you went off the road somewhere and need help finding back, here is everything we just did, as a whole:
import math
import pyxel
BALL_SIZE = 2
BALL_SPEED = 2
BAT_SIZE = 8
SCREEN_WIDTH = 255
SCREEN_HEIGHT = 120
class Vec2:
def __init__(self, x, y):
self.x = x
self.y = y
class Vec2_norm:
def __init__(self, x, y):
self.magnitude = math.sqrt(x * x + y * y)
self.x = x / self.magnitude * BALL_SPEED
self.y = y / self.magnitude * BALL_SPEED
class HitBox:
def __init__(self, x1, y1, x2, y2):
self.x1 = x1 # x-coordinate of top left corner
self.y1 = y1 # y-coordinate of top left corner
self.x2 = x2 # x-coordinate of bottom right corner
self.y2 = y2 # y-coordinate of bottom right corner
class Ball:
def __init__(self, px, py, vx, vy):
self.position = Vec2(px, py)
self.velocity = Vec2_norm(vx, vy)
def update(self):
self.position.x += self.velocity.x
self.position.y += self.velocity.y
if self.position.y >= SCREEN_HEIGHT - BALL_SIZE:
self.velocity.y = -self.velocity.y
if self.position.y <= BALL_SIZE:
self.velocity.y = -self.velocity.y
class Bat:
def __init__(self, px, py):
self.position = Vec2(px, py)
self.velocity = 0
self.hitBox = HitBox(
self.position.x - BAT_SIZE / 4,
self.position.y - BAT_SIZE,
self.position.x + BAT_SIZE / 4,
self.position.y + BAT_SIZE
)
def update(self):
self.position.y += self.velocity
self.hitBox = HitBox(
self.position.x - BAT_SIZE / 4,
self.position.y - BAT_SIZE,
self.position.x + BAT_SIZE / 4,
self.position.y + BAT_SIZE
)
if pyxel.btnp(pyxel.KEY_W):
self.velocity = -2
if pyxel.btnp(pyxel.KEY_S):
self.velocity = 2
if self.position.y - BAT_SIZE < 0:
self.position.y = BAT_SIZE
self.velocity = 0
if self.position.y + BAT_SIZE > SCREEN_HEIGHT:
self.position.y = SCREEN_HEIGHT - BAT_SIZE
self.velocity = 0
class App:
def __init__(self):
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT)
self.ball = Ball(20, 20, 2, 2)
self.bats = [Bat(10, 10), Bat(SCREEN_WIDTH - 10, 10)]
self.score = 0
pyxel.run(self.update, self.draw)
def update(self):
if pyxel.btnp(pyxel.KEY_Q):
pyxel.quit()
self.ball.update()
for bat in self.bats:
bat.update()
if (bat.hitBox.x1 < self.ball.position.x < bat.hitBox.x2
and bat.hitBox.y1 < self.ball.position.y < bat.hitBox.y2):
self.ball.velocity.x = -self.ball.velocity.x
self.score += 1
if self.ball.position.x >= SCREEN_WIDTH - BALL_SIZE:
pyxel.quit()
if self.ball.position.x <= BALL_SIZE:
pyxel.quit()
def draw(self):
pyxel.cls(0)
pyxel.circ(
self.ball.position.x,
self.ball.position.y,
BALL_SIZE,
7
)
for bat in self.bats:
pyxel.rect(
bat.hitBox.x1,
bat.hitBox.y1,
bat.hitBox.x2,
bat.hitBox.y2,
7
)
pyxel.text(
SCREEN_WIDTH / 2,
SCREEN_HEIGHT / 12,
str(self.score),
7
)
App()
Where to go from here
Alright, a very basic version of the game is done, but let’s be honest, it could be more exciting. Here are some suggestions about how to improve the game, that you can try on your own:
- To make the game less predictable, let’s change the ball’s angle with which he bounces back from the bat by a small random value. Hint: you will probably want to
from random import uniform
for this one.- More advanced: Make the angle change based on the position of the ball relative to the position of the bat at the time of contact.
- Make the game harder as it goes along, maybe increase the ball speed a little every 5 points (it isn’t technically necessary, but it would be good practice to rename the
BALL_SPEED
variable toball_speed
, since all-cap variable names generally indicate constants). - Use different color schemes. Maybe even change colors dynamically throughout the game (to indicate an increase in ball speed for example).
- There is a bug, that when the ball enters the bat from the bottom or top rather than the side, it will get kind of stuck there, maybe you can figure out what the problem is and fix it?
Edit: Stepping every aspect of this tutorial up (and adding some new ones): Check out this project on GitHub: github.com/timbledum/asteroids If you want to level up, examining this very well written and commented repository would be well-invested time.