Recall that we discussed two potential benefits for OOP:
Both of those benefits are very relevant for games. We often have many entities with complex data and behavior. We don’t want the infrastructure that manages time in the game, the screen, etc. to need “to know” about the details of all of those entities (such as how they are rendered to the screen), instead the entity objects can implement a common interface that encapsulates or “abstracts away” those details. Many of the entities will have similar features, e.g., a position, that can be implemented in shared base classes to facilitate code reuse (i.e., reduce code duplication).
Imagine you were going to create a dodgeball-type game where the player had to move around to avoid a set a moving obstacles bouncing around inside a room. Let’s brainstorm some of the game functionality:
What do you notice about the blue and red words? The red words, or nouns, are the data in our game and the blue words, or verbs, are the actions we to apply to those nouns. The former often maps to classes (or their instance variables) and the latter to methods. In this example, we could create classes for the
Obstacle entities in our game, and methods to move those objects, check if they collide, etc.
Now that we have defined our classes, we start to think about what kind of data is associated with each of our classes. We notice in the first bullet there is another noun, “position”, which is described as belonging to a player. That suggests it might be an instance variable of player. And as we think through what we need to implement our game, we will likely need to keep track of each entities’ position, size/shape and in the case of the obstacles their velocity/heading.
In class and for our upcoming lab we will use the PyGame library to implement simple 2-D games. PyGame provides tools for managing time, representing shapes (e.g., rectangles), obtaining user inputs, rendering to the screen and more. As with many of the libraries we have used this semester we will only scratch the surface of this module. I encourage you to check out the documentation to learn more about PyGame.
To use PyGame you will need to install the package with the Thonny “Tools -> Manage Packages” command. Enter
pygame into the search box, click “Find package from PyPI” then “Install”.
The heart of our games (and many others) is the game loop. It has three main steps:
Since these steps are common to many games, we want to separate those common aspects from the specific details of our games. We will implement our game entities as objects with methods for creation, update and rendering that can be used by the code in the game loop. Notice that our game objects change extensively, but the game loop remains similar across our different examples.
As a starting point, we are going to implement an animation where a box moves around the screen bouncing off the walls. We will implement the box as an object (instance) of the
Box class. The
Box class will maintain the position and size of the box using a
pygame.Rect object. By doing so we can take advantage of PyGame’s existing code for rendering rectangles, checking if two rectangles overlap, and more. Here is an implementation of that class. Check out the entire game program to see this class in context (along with additional comments, docstrings, etc.)
class Box(): def __init__(self): self.rect = pygame.Rect(0, 200, 50, 50) self.velocity = [80, 80] def update(self, dt): self.rect.x += self.velocity*dt self.rect.y += self.velocity*dt # Use the relevant instance variables to check if rectangle has hit the edges # of the screen (0,0 is the upper left corner) if (self.rect.left < 0 or self.rect.right > SCREEN_WIDTH): self.velocity *= -1 if (self.rect.top < 0 or self.rect.bottom > SCREEN_HEIGHT): self.velocity *= -1
Notice that the
update method encapsulates all the operations for determining the new position of of the box after some time-step
dt. The caller does not need to know how the
Box maintains its state internally, only the interface for performing the update.
In contrast, if you check out the game loop, you see that the rendering code accesses the box’s rectangle instance variables and needs to know the color. That operation is a similarly good candidate for abstraction as a method. Why? If we wanted to change to a different underlying shape, or data structure, we shouldn’t need to change our game loop. The game loop only needs to know that we want to render a
Box, not how we do so…
That is the following line
# Specify color as a RGB tuple pygame.draw.rect(screen, (0, 0, 255), box.rect)
could become a render method:
def render(self, screen): pygame.draw.rect(screen, (0, 0, 255), self.rect)
the key difference is that we now access
rect as an instance variable of
self. With that change, the game loop is not dependent on the internal implementation of
Box, instead it just interacts with that entity via methods. We have successfully “abstracted” those operations (creation, update, rendering).
We now want to add a player, represented by a red square that we can move around the screen trying to avoid multiple blue boxes. An initial implementation might look something like:
class Player(): def __init__(self): self.rect = pygame.Rect(0, 0, 50, 50) def render(self, screen): pygame.draw.rect(screen, (255, 0, 0), self.rect)
We notice that there are many similarities between
Box, and specifically
Player is a red box without velocity. A better description might be that
Player is a red box that starts at 0,0 and
Obstacle is a blue
Box with velocity. As we saw last time we can implement that “is” relationship via inheritance:
class Box(): def __init__(self, x, y, width, height, color): self.rect = pygame.Rect(x, y, width, height) self.color = color def render(self, screen): pygame.draw.rect(screen, self.color, self.rect) class Obstacle(Box): def __init__(self): super().__init__(0, 200, 50, 50, (0, 0, 255)) self.velocity = [80,80] def update(self, dt): ... class Player(Box): def __init__(self): super().__init__(0, 0, 50, 50, (255, 0, 0))
Here we create a common base class,
color instance variables and a suitable
render method and then two derived classes that customize the initialization of those objects and in the case of
Obstacle, add a
velocity instance variable and
update methods. By taking advantage of inheritance we avoid duplicating code in the
Obstacle and in the
When we create multiple obstacles, we want them to start in random locations on the screen and with random velocities (not just in the same spot). Where could we make that change? The
__init__ method is a natural place to perform such initialization. By implementing that code in the initializer, nothing about the game loop changes, it still invokes
Obstacle() to create obstacles.
Check out the full game program. We have extended the game loop to handle user inputs. The different arrow keys shift the position of the player. We added a pair of “shift” methods to
Player to implement those shifts. While we could have updated the underlying
rect instance variable directly, doing so would have coupled the game loop to that implementation. If we changed the way we stored the position and size of the player, i.e., replaced the
rect instance variable with some other data structure, we would need to update the game loop as well. By providing an “abstract” interface, any object that provides the
render methods could be used as the player.
We want the game to end if the player and an obstacle collide (that is the goal is to avoid the obstacles). This is a very common operation in games and so PyGame provides a
colliderect method that returns True if two rectangles overlap. How and where would you use
colliderect? A natural approach is to implement a
collide method in the
Box class that wraps
def collide(self, other): return self.rect.colliderect(other.rect)
That method is inherited by both
Obstacle and either can be used as the
other argument, thus we could invoke
player.collide(obstacle) or vice-versa. As with
collide method separates the interface from the implementation.
Credits: Adapted from Philip Caplan.