This post has been ported from the old blog and slightly edited on the 15th of May 2022.
“Python’s most underrated game engine for beginners” is by far the most popular post on this blog and it seems to help a lot of people making their first steps with Python, so I decided to expand on this post by 1) refactoring the code to be both better and more pythonic and 2) solve the challenges I posted at the bottom of the first post utilizing the advantages of the new, refactored code.
For this tutorial, I will constantly refer back to the original code, so it might be helpful if you have a copy of it somewhere nearby.
The first thing we need to address is our Vec2 classes. We define two of them, one for normal vectors and one for normalized vectors. In most circumstances, this would be considered bad practice, because as you expand the functionality of your Vec2
class, you also have to copy that functionality over to the Vec2_norm
class, which isn’t very DRY, to say the least.
So let’s merge these two classes into one:
class Vec2:
def __init__(self, x, y):
self.x = x
self.y = y
self.magnitude = math.sqrt(x * x + y * y)
def normalized(self):
return Vec2(self.x / self.magnitude, self.y / self.magnitude)
Cool, so now we can use our Vec2
class like we are used to and when we want to use a normalized vector we can use its normalized(
)` method. Easy peasy.
Above I mentioned something about expanding our Vec2
classes functionality. Well, what do I mean by that? After all, we have been getting by just fine with just an x
, a y
and a magnitude
attribute. Now we can even normalize a vector by calling a single method, what else could we want from our class, right?
Consider this: How would you add two vector objects together? Probably something like this:
vector_1 = Vec2(2, 2)
vector_2 = Vec2(3, 3)
vector_sum = Vec2(vector_1.x + vector_2.x, vector_1.y + vector_2.y)
That’s an awful lot of code for such a simple operation though, isn’t it? Shouldn’t it be a simple as vector_sum = vector_1 + vector2
? This is where Python’s magic methods come into play. You see, we can achieve exactly this behavior, by specifying our vector’s **add**
method like this:
def __add__(self, other):
return Vec2(self.x + other.x, self.y + other.y)
Now, whenever we use the + operator with two vector objects it will perform the addition and return a new vector object with the new values.
Here is a little exercise: Try and define magic methods for all the other basic arithmetic operations and when you have done that I show you my solution. The methods you are looking for are called **sub**
, **mul**
and **truediv**
.
Have you done it? Great. Here is what I have:
class Vec2:
def __init__(self, x, y):
self.x = x
self.y = y
self.magnitude = math.sqrt(x * x + y * y)
def __add__(self, other):
if not isinstance(other, Vec2):
raise TypeError("Only 2 Vec2 can be added to each other!")
return Vec2(self.x + other.x, self.y + other.y)
def __sub__(self, other):
if not isinstance(other, Vec2):
raise TypeError("Only a Vec2 can be subtracted from another!")
return Vec2(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
if not isinstance(scalar, (int, float)):
raise TypeError("A Vec2 can only be mulitplied by a scalar!")
return Vec2(self.x * scalar, self.y * scalar)
def __truediv__(self, scalar):
if not isinstance(scalar, (int, float)):
raise TypeError("A Vec2 can only be divided by a scalar!")
return Vec2(self.x / scalar, self.y / scalar)
def normalized(self):
return self / self.magnitude
Easy right? You are probably noticing two things in my code though: Firstly, I have included checks to make sure the operators are used with the right data type since vectors can only be added to and subtracted from other vectors, yet can only be multiplied and divided by single numbers (called scalars). Secondly, I have refactored the normalized()
method yet again to take advantage of this new functionality right away. Looking mighty fine, let’s move on.
Next, let’s look at our HitBox
class. The first thing we should notice is that this class actually has no functionality at all. It consists exclusively of attributes and has no methods. Whenever you encounter a class like this, it is a perfect opportunity to refactor, since a class like this can (and should) be refactored into a collection type called namedtuple
. So after adding the from collections import namedtuple
statement at the top of our file, let’s refactor our HitBox to look like this: HitBox = namedtuple("HitBox", "x1 y1 x2 y2")
. Single line. Easy as pie. If something about this confuses you, check out the official documentation here.
The best part is that we can actually use this like our original HitBox
class, so we can leave the parts of our code that used the class as they are, but not only that, since this is now a sequence type, we have now implicitly made any HitBox
iterable. “Yeah that’s nice”, you might say, “but when do I realistically need to iterate through the coordinates of a hitbox? That’s not very useful.”
Well, let’s take a look at the draw
method of our original App
class. Until now it looks like this:
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)
Notice how we need to reference every single coordinate when drawing a rectangle for our bats? You see, being iterable does not only mean that we can iterate through it, it also means that we can make use of Python’s very powerful packing/unpacking features. Here we can refactor the relevant line to pyxel.rect(\*bat.hitBox, 7)
, which is way prettier and to the point.
For the rest of the script, the refactoring is rather unexciting, but what we definitely should do, is to move the BALL_SPEED, BALL_SIZE and BRICK_SIZE constants to where they belong: Their respective classes. You can do that yourself or copy it from below, where you find the complete script as we have it right now, just so we are on the same page:
from collections import namedtuple
import math
import pyxel
SCREEN_WIDTH = 255
SCREEN_HEIGHT = 120
HitBox = namedtuple("HitBox", "x1 y1 x2 y2")
class Vec2:
def __init__(self, x, y):
self.x = x
self.y = y
self.magnitude = math.sqrt(x * x + y * y)
def __add__(self, other):
if not isinstance(other, Vec2):
raise TypeError("Only 2 Vec2 can be added to each other!")
return Vec2(self.x + other.x, self.y + other.y)
def __sub__(self, other):
if not isinstance(other, Vec2):
raise TypeError("Only a Vec2 can be subtracted from another!")
return Vec2(self.x - other.x, self.y - other.y)
def __mul__(self, scalar):
if not isinstance(scalar, (int, float)):
raise TypeError("A Vec2 can only be mulitplied by a scalar!")
return Vec2(self.x * scalar, self.y * scalar)
def __truediv__(self, scalar):
if not isinstance(scalar, (int, float)):
raise TypeError("A Vec2 can only be divided by a scalar!")
return Vec2(self.x / scalar, self.y / scalar)
def normalized(self):
return self / self.magnitude
class Ball:
def __init__(self, px, py, vx, vy, speed=2, size=2):
self.position = Vec2(px, py)
self.speed = speed
self.size = size
self.velocity = Vec2(vx, vy).normalized() * self.speed
def changeSpeedBy(self, number):
self.speed *= number
self.velocity = self.velocity.normalized() * self.speed
def update(self):
self.position.x += self.velocity.x
self.position.y += self.velocity.y
if self.position.y >= SCREEN_HEIGHT - self.size:
self.velocity.y = -self.velocity.y
if self.position.y <= self.size:
self.velocity.y = -self.velocity.y
class Bat:
def __init__(self, px, py, size=8):
self.position = Vec2(px, py)
self.velocity = 0
self.size = size
self.hitBox = HitBox(
self.position.x - self.size / 4,
self.position.y - self.size,
self.position.x + self.size / 4,
self.position.y + self.size,
)
def update(self):
self.position.y += self.velocity
self.hitBox = HitBox(
self.position.x - self.size / 4,
self.position.y - self.size,
self.position.x + self.size / 4,
self.position.y + self.size,
)
if pyxel.btnp(pyxel.KEY_W):
self.velocity = -2
if pyxel.btnp(pyxel.KEY_S):
self.velocity = 2
if self.position.y - self.size < 0:
self.position.y = self.size
self.velocity = 0
if self.position.y + self.size > SCREEN_HEIGHT:
self.position.y = SCREEN_HEIGHT - self.size
self.velocity = 0
class App:
def __init__(self):
pyxel.init(SCREEN_WIDTH, SCREEN_HEIGHT)
self.ball = Ball(20, 20, 2, 2)
self.score = 0
pyxel.run(self.update, self.draw)
def update(self):
if pyxel.btnp(pyxel.KEY_Q):
pyxel.quit()
self.ball.update()
if self.ball.position.x >= SCREEN_WIDTH - self.size:
pyxel.quit()
if self.ball.position.x <= self.size:
pyxel.quit()
def draw(self):
pyxel.cls(0)
pyxel.circ(self.ball.position.x, self.ball.position.y, self.size, 7)
pyxel.text(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 12, str(self.score), 7)
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 - self.ball.size:
pyxel.quit()
if self.ball.position.x <= self.ball.size:
pyxel.quit()
def draw(self):
pyxel.cls(0)
pyxel.circ(self.ball.position.x, self.ball.position.y, self.ball.size, 7)
for bat in self.bats:
pyxel.rect(*bat.hitBox, 7)
pyxel.text(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 12, str(self.score), 7)
App()
Now that we have a much better version of the original code, let’s try and implement the improvements I was suggesting at the end of the first post, these were:
- To make the game less predictable, let’s change the ball’s angle by 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?
Change the balls angle based on the position of the ball relative to the position of the bat at the time of contact
Let’s just go for the more advanced version right away. First, let’s clarify what we mean by “angle” and what we mean by “relative to” in terms of our code. When we say “angle, we mean that not only the x-attribute of our balls velocity vector changes but also the y-attribute. When we say “relative to” we mean to compare the ball’s y-position with the bat’s y-position.
Let’s start looking at our code. We check for the collision between ball and bat starting on line 113 inside our App
’s update
method. Instead of just reversing the direction of the ball on the x-axis like we are doing right now, let’s also play with the y-value of the velocity vector.
The problem here is that this can become messy since we have lots of cases we want to avoid. We don’t want to make it so big for example, that the ball is just moving in a straight vertical line, we also don’t want it to lock in to just go perfectly horizontal near the edge of the screen forever (which could potentially happen). Put aptly, we want to keep control over the range of acceptable y-values. In other words: We want to keep the ball from getting crazy.
Here is how I would go about this. Knowing that this can get messy, I will not attempt to edit the code right then and there, but rather define its own function for this behavior.
The idea is this: We take in a value I’m calling offset, which is what determines how much the value should be changed. Then I’m also defining a range of inputs (minimum and maximum value of the offset) and a range of outputs (this is how I keep control over what I want to allow). Then I am mapping my range of inputs to the range of outputs and translate my offset to fit the mapping. Phu, that was a lot, let’s see it in action:
def mutate_y_value(offset, min_input, max_input, min_output, max_output):
# determining the size of each range
offset_range = max_offset - min_offset
output_range = max_output - min_output
# converting the offset_range to a range 0-1
offset_scaled = float(offset - min_offset) / float(offset_range)
# returning the mapped value
return min_output + (offset_scaled * output_range)
This would work, but writing a function like this could be considered bad practice. Functionality like this should be encapsulated by the entity it belongs to. I would argue this functionality belongs to our Vec2
class. Rewritten as a method of the Vec2
class, it now looks like this:
def mutate_y_value_in_range(
self, offset, min_offset, max_offset, min_output, max_output
):
offset_range = max_offset - min_offset
output_range = max_output - min_output
offset_scaled = float(offset - min_offset) / float(offset_range)
return Vec2(self.x, self.y + min_output + (offset_scaled * output_range))
Notice how I have added the self
argument and how I am utilizing it to return a new vector object, to make its application easier.
So how do we apply this function? In our App’s update method, between reversing the ball’s velocity’s x-value, and incrementing the score, we call our new method:
self.ball.velocity = (
self.ball.velocity.mutate_y_value_in_range(
(self.ball.position.y - bat.position.y),
-bat.size,
bat.size,
-1.5,
1.5,
).normalized()
* self.ball.speed
)
Here we assign a new vector to self.ball.velocity
. Keep in mind the x-value has already been reversed. The first argument here is the difference between the ball’s y-position and the bat’s y-position. The min_offset
and max_offset
arguments cannot possibly be bigger than the size of the bats (because in that case, they would not touch the bat). The output I determined by just playing around a little. The bigger the range the sharper the angle. Whatever feels right. I went for 1.5. Time to give it a test run, maybe take a break, and move on.
Increase the ball speed a little every 5 points
If you powered through until this point you are beyond the level where I need to explain this one, so I’m just pasting the code here.
self.score += 1 # this line already exists
if self.score % 5 == 0:
self.ball.speed += 1
self.ball.velocity = (
self.ball.velocity.normalized() * self.ball.speed
)
Use different color schemes. Maybe even change colors dynamically throughout the game
Let’s use the same if
statement for this one, but before we do that we have to substitute all the colors we are using right now with variables we can change dynamically. Right now, we are using two different colors, a foreground color for the ball the bats and the text and a background color to fill out the window. So let’s define it this way: At the top of the file, create a dictionary to hold that information with something like colors = dict(foreground=7, background=0)
. Luckily, we only use colors in our App
’s draw
method, so we don’t have to search the whole file to replace colors. After replacing these values the draw
method should look like this:
def draw(self):
pyxel.cls(colors["background"])
pyxel.circ(
self.ball.position.x,
self.ball.position.y,
self.ball.size,
colors["foreground"],
)
for bat in self.bats:
pyxel.rect(*bat.hitBox, colors["foreground"])
pyxel.text(
SCREEN_WIDTH / 2, SCREEN_HEIGHT / 12, str(self.score), colors["foreground"]
)