Tutorial 2: Pong

Now that you've seen the basics of the SGE, it's time to create an actual game. Although Pong might seem extremely simple, it will give you a great foundation for developing more complex games in the future.

Start out by setting up the project like we did in the Hello World tutorial.

Adding Game Logic

The Game Class

For our sge.dsp.Game class, we want to of course provide a way to exit the game, and in this case, we are also going to provide a way to pause the game. Just for the heck of it, let's also allow the player to take a screenshot by pressing F8 and toggle fullscreen by pressing F11.

Let's take it one event at a time. Our close event is simple enough:

def event_close(self):
    self.end()

Our key press event is slightly more involved. To take a screenshot, we simply use a combination of sge.gfx.Sprite.from_screenshot() and sge.gfx.Sprite.save(). To toggle fullscreen, we simply change the value of sge.dsp.Game.fullscreen. To pause the game, we use sge.dsp.Game.pause(). We end up with this:

def event_key_press(self, key, char):
    if key == 'f8':
        sge.gfx.Sprite.from_screenshot().save('screenshot.jpg')
    elif key == 'f11':
        self.fullscreen = not self.fullscreen
    elif key == 'escape':
        self.event_close()
    elif key in ('p', 'enter'):
        self.pause()

This is incomplete, though. When sge.dsp.Game.pause() is called, the game enters a special loop where normal events are ignored. In their place, we need to use "paused" events to give the player a chance to unpause. We also should allow the player to quit the game while it is paused. To achieve these goals, we add the special events, sge.dsp.Game.event_paused_key_pressed() and sge.dsp.Game.event_paused_close():

def event_paused_key_press(self, key, char):
    if key == 'escape':
        # This allows the player to still exit while the game is
        # paused, rather than having to unpause first.
        self.event_close()
    else:
        self.unpause()

def event_paused_close(self):
    # This allows the player to still exit while the game is paused,
    # rather than having to unpause first.
    self.event_close()

In this case, we are defining the paused key press event to unpause the game when any key except for the Esc key is pressed.

The Object Classes

sge.dsp.Object objects are things in a game that we want to be displayed in a room. These objects tend to represent players, enemies, tiles, decorations, and pretty much anything else you can think of.

For Pong, we need three objects: the two players, and the ball. We will define two sub-classes of sge.dsp.Object for this purpose: Player and Ball.

Player

Player is used for the paddles. These are what the players control.

For Player, the difference between different objects is which player controls it. Every other difference (the position, the controls, and the direction it hits the ball) can be easily derived from that. We are therefore going to define Player.__init__() to reflect this.

Player.__init__() will take a single argument, player. This argument will indicate which player the object is for: 1 for player 1, or 2 for player 2. We will set a few attributes based on this:

  • up_key will indicate the key that moves the paddle up. We will set it to "w" for player 1, or "up" for player 2.

  • down_key will indicate the key that moves the paddle down. We will set it to "s" for player 1, or "down" for player 2.

  • x is an attribute inherited from sge.dsp.Object which indicates the horizontal position of the object. We will set this based on a constant we will define (technically just a variable, since Python doesn't support constants) called PADDLE_XOFFSET: PADDLE_XOFFSET for player 1, or sge.game.width - PADDLE_XOFFSET for player 2. We will define PADDLE_XOFFSET near the top of our code file, beneath imports, as 32.

  • hit_direction will indicate the direction the paddle hits the ball. We will set it to 1 for player 1, and -1 for player 2.

Additionally, certain attributes inherited from sge.dsp.Object will be the same for both Player objects. y will always be sge.game.height / 2 (vertically centered). sprite will always be paddle_sprite (a sprite we will create later). checks_collisions will always be False, since player objects don't need to check for collisions with each other; we can therefore leave all collision checking to the ball object.

All attributes inherited from sge.dsp.Object will be defined by passing their values to sge.dsp.Object.__init__(), which we will call with super().__init__(*args, **kwargs). This makes our Player.__init__() defintion an extension, rather than an override, of sge.dsp.Object.__init__(), which is important; overriding this method would be likely to break something.

Our definition of Player.__init__`() ends up looking something like this:

def __init__(self, player):
    if player == 1:
        self.joystick = 0
        self.up_key = "w"
        self.down_key = "s"
        x = PADDLE_XOFFSET
        self.hit_direction = 1
    else:
        self.joystick = 1
        self.up_key = "up"
        self.down_key = "down"
        x = sge.game.width - PADDLE_XOFFSET
        self.hit_direction = -1

    y = sge.game.height / 2
    super().__init__(x, y, sprite=paddle_sprite, checks_collisions=False)

We need to allow the players to move the paddles. We could do this by using key press events, but since we would like the players to be able to continuously move the paddles by holding down the key, the proper way to do this is to check for the state of the keys every frame and move accordingly.

sge.keyboard.get_pressed() returns the state of a key on the keyboard. We will check this in the step event to decide how the paddle should move on any given frame. The step event, defined by sge.dsp.Object.event_step(), is an event which always executes every frame.

What we will do is subtract the state of up_key from the state of down_key. This will give us -1 if only up_key is pressed, 1 if only down_key is pressed, and 0 if neither or both keys are pressed. We can multiply this result by a constant, which we will call PADDLE_SPEED, to get the amount that the paddle should move this frame, and assign this value to the player's sge.dsp.Object.yvelocity, an attribute which indicates the number of pixels an object will move vertically each frame. We will define PADDLE_SPEED as 4.

This isn't quite enough, though. With just this, the paddle can be moved off-screen! To prevent this from happening, we will check the player object's bbox_top and bbox_bottom values; these indicate the current location of the object's bounding box. If bbox_top is less than 0, we will set it to 0. If bbox_bottom is greater than sge.game.current_room.height, we will set it to sge.game.current_room.height. sge.game.current_room, as its name implies, indicates the currently running sge.game.Room object.

Our step event ends up looking something like this:

def event_step(self, time_passed, delta_mult):
    # Movement
    key_motion = (sge.keyboard.get_pressed(self.down_key) -
                  sge.keyboard.get_pressed(self.up_key))

    self.yvelocity = key_motion * PADDLE_SPEED

    # Keep the paddle inside the window
    if self.bbox_top < 0:
        self.bbox_top = 0
    elif self.bbox_bottom > sge.game.current_room.height:
        self.bbox_bottom = sge.game.current_room.height

Ball

Ball is the ball. It is bounced back and forth by the players. If it touches the top or bottom edge of the screen, it bounces off. If it passes one of the players, the other player gets a point and the ball is returned to the playing field.

Any Ball object is always going to have the same initial attributes as any other Ball object, so much like what we did with Player, we are going to define a custom Ball.__init__().

In this case, it's much simpler: x and y are going to start at the center of the screen, and sprite is going to be ball_sprite. These are attributes inherited from sge.dsp.Object, so we indicate them in a call to super().__init__. Ball.__init__() ends up as:

def __init__(self):
    x = sge.game.width / 2
    y = sge.game.height / 2
    super().__init__(x, y, sprite=ball_sprite)

Since we want to serve the ball both at the start of the game and every time the ball passes a player, we should define a Ball.serve() method. This method needs to do two things: first, it needs to return the ball to its original position in the center. Second, it needs to set the speed so that it moves either straight to the left or straight to the right. If a direction isn't specified, it needs to choose a direction at random.

For the first task, we can use sge.dsp.Object.xstart and sge.dsp.Object.ystart. These attributes indicate the original position of an object when it was first created, which in the case of Ball objects is in the center of the screen.

For the second task, we have an argument called direction. If it is None, it randomly becomes either 1 or -1. The value is then multiplied by a constant called BALL_START_SPEED, which we will set to 2, and this becomes the ball's sge.dsp.Object.xvelocity value. The ball's sge.dsp.Object.yvelocity value is then set to 0.

The result looks like this:

def serve(self, direction=None):
    if direction is None:
        direction = random.choice([-1, 1])

    self.x = self.xstart
    self.y = self.ystart

    # Next round
    self.xvelocity = BALL_START_SPEED * direction
    self.yvelocity = 0

Note

Since we are now using the random module, we need to also import it at the top of our code file.

When the ball is created, we want to serve it immediately. we will put this in the create event, which is defined by sge.dsp.Object.event_create(). The create event happens whenever the object is created in the room. This is the create event of Ball:

def event_create(self):
    self.serve()

For Ball's step event, we need to do two things: cause the ball to bounce off of the top and bottom edges of the screen, and serve the ball when it passes the left or right edge of the screen.

For the first task, we do the same thing we did with Player, but we also set whether yvelocity is positive or negative; we make it negative when the ball touches the bottom, and positive when the ball touches the top.

For the second task, we do a similar check, but we phrase the check such that the ball needs to be completely outside of the room, rather than just touching the edge. We do this by checking bbox_right against the left edge, and bbox_left against the right edge. When the ball is outside the screen, we serve it in the direction of the player it passed (so that the player who lost the round gets initial control of the ball).

Our step event for Ball ends up looking something like this:

def event_step(self, time_passed, delta_mult):
    # Scoring
    if self.bbox_right < 0:
        self.serve(-1)
    elif self.bbox_left > sge.game.current_room.width:
        self.serve(1)

    # Bouncing off of the edges
    if self.bbox_bottom > sge.game.current_room.height:
        self.bbox_bottom = sge.game.current_room.height
        self.yvelocity = -abs(self.yvelocity)
    elif self.bbox_top < 0:
        self.bbox_top = 0
        self.yvelocity = abs(self.yvelocity)

Now, we need to allow the players to repel the ball. We will do this with a collision event. Collision events, controlled by sge.dsp.Object.event_collision(), occur when two objects touch each other.

We first need to verify what type of object we're colliding with. The most straightforward way is to use isinstance() to check whether or not the object being collided with, which is passed on to the other argument, is an instance of Player. We write the collision code for these two objects under this check.

The most straightforward way to do this is with directional collision detection, but we are going to instead use Player.hit_direction to determine what to do. If the other.hit_direction is 1, we bounce the ball to the right. Otherwise, we bounce the ball to the left.

We need to make the ball accelerate each time the ball hits a paddle, so that the round goes faster over time. We will store the amount of acceleration in a constant called BALL_ACCELERATION, which we will define as 0.2. We will then set self.xvelocity to (abs(self.xvelocity) + BALL_ACCELERATION) * other.hit_direction.

We also need to make the ball's vertical movement change based on where it hits the paddle. To do this, we will subtract other.y from self.y and multiply that by a constant called PADDLE_VERTICAL_FORCE, which we will define as 1 / 12; this value will be added to self.yvelocity.

There is one problem left, though it is not particularly obvious. The way we have it set up at this point, the ball will eventually move so fast that it will fail to collide with the paddles. This is due to how movement works; it's not actual movement, but rather a slight change of position done every frame. If that change of position is too much, the ball can pass right over a paddle.

To prevent this, we need to set a limit for how fast the ball can move horizontally. Instead of just multiplying (abs(self.xvelocity) + BALL_ACCELERATION) by other.hit_direction, we multiply the smallest out of that, and a new constant called BALL_MAX_SPEED, by other.hit_direction. We will define BALL_MAX_SPEED as 15.

Our collision event ends up looking something like this:

def event_collision(self, other, xdirection, ydirection):
    if isinstance(other, Player):
        if other.hit_direction == 1:
            self.bbox_left = other.bbox_right + 1
        else:
            self.bbox_right = other.bbox_left - 1

        self.xvelocity = min(abs(self.xvelocity) + BALL_ACCELERATION,
                             BALL_MAX_SPEED) * other.hit_direction
        self.yvelocity += (self.y - other.y) * PADDLE_VERTICAL_FORCE

Starting the Game

It's time to get our game started.

We are going to pass some arguments to the creation of our Game object: we are going to define width as 640, height as 480, fps as 120, and window_text as "Pong". Specify them as keyword arguments.

Loading Sprites

We need two sprites: a paddle sprite and a ball sprite. We also need a black background with a line down the middle. We could draw these in an image editor and load them, but since they are so simple, we are going to generate them dynamically instead.

Sprites are stored as sge.gfx.Sprite objects, so we are going to create two of them:

paddle_sprite = sge.gfx.Sprite(width=8, height=48, origin_x=4, origin_y=24)
ball_sprite = sge.gfx.Sprite(width=8, height=8, origin_x=4, origin_y=4)

sge.gfx.Sprite.origin_x and sge.gfx.Sprite.origin_y indicate the origin of the sprite. In this case, we are setting the origins to the center of the sprites. This is necessary for our method of determining how the paddles affect vertical speed to work, and it also makes symmetry easier.

Currently, both of these sprites are blank. We need to draw the images on them. In this case, we will just draw white rectangles that fill the entirety of the sprites, which can be done with sge.gfx.Sprite.draw_rectangle():

paddle_sprite.draw_rectangle(0, 0, paddle_sprite.width,
                             paddle_sprite.height, fill=sge.gfx.Color("white"))
ball_sprite.draw_rectangle(0, 0, ball_sprite.width, ball_sprite.height,
                           fill=sge.gfx.Color("white"))

Loading Backgrounds

Now we need a background. Our sprites are white, so we need a black background. We could of course leave it just at that, but that would be boring, so we are also going to also have a white line in the middle. We can do this easily by using the paddle sprite as a background layer. Background layers are special objects that indicate sprites that are used in a background. We create the layer, put it in a list, and pass that list onto sge.gfx.Background.__init__()'s layers argument:

layers = [sge.gfx.BackgroundLayer(paddle_sprite, sge.game.width / 2, 0, -10000,
                                  repeat_up=True, repeat_down=True)]
background = sge.gfx.Background(layers, sge.gfx.Color("black"))

The fourth argument of sge.BackgroudLayer.__init__() is the layer's Z-axis value. The Z-axis is used to determine what objects are in front of what other objects; objects with a higher Z-axis value are closer to the viewer. The default Z-axis value is 0. Since we want all objects to be in front of the layer, we set its Z-axis value to a very low negative value.

Creating Objects

Don't forget to create our objects! In player1, store a Player object with the player argument specified as 1. In player2, store a Player object with the player argument specified as 2. Finally, create a Ball object and store it in ball. Put all of these objects in a list and assign this list to a variable called objects.

Creating Rooms

Create a Room object. Specify the first argument as objects, and specify the keyword argument background as background. Don't forget to assign it to sge.game.start_room!

Making the Mouse Invisible

Since we don't need to see the mouse cursor, we will hide it. To do this, set sge.game.mouse.visible to False.

Starting the Game

Add a call to sge.game.start() at the end, under a check for the value of __name__.

The Final Result

You should now have a script that looks something like this:

#!/usr/bin/env python3

# Pong Example
#
# To the extent possible under law, the author(s) have dedicated all
# copyright and related and neighboring rights to this software to the
# public domain worldwide. This software is distributed without any
# warranty.
#
# You should have received a copy of the CC0 Public Domain Dedication
# along with this software. If not, see
# <http://creativecommons.org/publicdomain/zero/1.0/>.

import random

import sge

PADDLE_XOFFSET = 32
PADDLE_SPEED = 4
PADDLE_VERTICAL_FORCE = 1 / 12
BALL_START_SPEED = 2
BALL_ACCELERATION = 0.2
BALL_MAX_SPEED = 15


class Game(sge.dsp.Game):

    def event_key_press(self, key, char):
        global game_in_progress

        if key == 'f8':
            sge.gfx.Sprite.from_screenshot().save('screenshot.jpg')
        elif key == 'f11':
            self.fullscreen = not self.fullscreen
        elif key == 'escape':
            self.event_close()
        elif key in ('p', 'enter'):
            self.pause()

    def event_close(self):
        self.end()

    def event_paused_key_press(self, key, char):
        if key == 'escape':
            # This allows the player to still exit while the game is
            # paused, rather than having to unpause first.
            self.event_close()
        else:
            self.unpause()

    def event_paused_close(self):
        # This allows the player to still exit while the game is paused,
        # rather than having to unpause first.
        self.event_close()


class Player(sge.dsp.Object):

    def __init__(self, player):
        if player == 1:
            self.up_key = "w"
            self.down_key = "s"
            x = PADDLE_XOFFSET
            self.hit_direction = 1
        else:
            self.up_key = "up"
            self.down_key = "down"
            x = sge.game.width - PADDLE_XOFFSET
            self.hit_direction = -1

        y = sge.game.height / 2
        super().__init__(x, y, sprite=paddle_sprite, checks_collisions=False)

    def event_step(self, time_passed, delta_mult):
        # Movement
        key_motion = (sge.keyboard.get_pressed(self.down_key) -
                      sge.keyboard.get_pressed(self.up_key))

        self.yvelocity = key_motion * PADDLE_SPEED

        # Keep the paddle inside the window
        if self.bbox_top < 0:
            self.bbox_top = 0
        elif self.bbox_bottom > sge.game.current_room.height:
            self.bbox_bottom = sge.game.current_room.height


class Ball(sge.dsp.Object):

    def __init__(self):
        x = sge.game.width / 2
        y = sge.game.height / 2
        super().__init__(x, y, sprite=ball_sprite)

    def event_create(self):
        self.serve()

    def event_step(self, time_passed, delta_mult):
        # Scoring
        if self.bbox_right < 0:
            self.serve(-1)
        elif self.bbox_left > sge.game.current_room.width:
            self.serve(1)

        # Bouncing off of the edges
        if self.bbox_bottom > sge.game.current_room.height:
            self.bbox_bottom = sge.game.current_room.height
            self.yvelocity = -abs(self.yvelocity)
        elif self.bbox_top < 0:
            self.bbox_top = 0
            self.yvelocity = abs(self.yvelocity)

    def event_collision(self, other, xdirection, ydirection):
        if isinstance(other, Player):
            if other.hit_direction == 1:
                self.bbox_left = other.bbox_right + 1
            else:
                self.bbox_right = other.bbox_left - 1

            self.xvelocity = min(abs(self.xvelocity) + BALL_ACCELERATION,
                                 BALL_MAX_SPEED) * other.hit_direction
            self.yvelocity += (self.y - other.y) * PADDLE_VERTICAL_FORCE

    def serve(self, direction=None):
        if direction is None:
            direction = random.choice([-1, 1])

        self.x = self.xstart
        self.y = self.ystart

        # Next round
        self.xvelocity = BALL_START_SPEED * direction
        self.yvelocity = 0


# Create Game object
Game(width=640, height=480, fps=120, window_text="Pong")

# Load sprites
paddle_sprite = sge.gfx.Sprite(width=8, height=48, origin_x=4, origin_y=24)
ball_sprite = sge.gfx.Sprite(width=8, height=8, origin_x=4, origin_y=4)
paddle_sprite.draw_rectangle(0, 0, paddle_sprite.width, paddle_sprite.height,
                             fill=sge.gfx.Color("white"))
ball_sprite.draw_rectangle(0, 0, ball_sprite.width, ball_sprite.height,
                           fill=sge.gfx.Color("white"))

# Load backgrounds
layers = [sge.gfx.BackgroundLayer(paddle_sprite, sge.game.width / 2, 0, -10000,
                                  repeat_up=True, repeat_down=True)]
background = sge.gfx.Background(layers, sge.gfx.Color("black"))

# Create objects
player1 = Player(1)
player2 = Player(2)
ball = Ball()
objects = [player1, player2, ball]

# Create rooms
sge.game.start_room = sge.dsp.Room(objects, background=background)

sge.game.mouse.visible = False


if __name__ == '__main__':
    sge.game.start()

This is a basically complete Pong game, but it lacks some features. First, this game doesn't keep track of the score. It is left up to the players to keep track of who is winning. Second, there is no sound. We should fix both of these problems.

Additionally, it would be nice if our game could support joystick input.

In the next tutorial, we will improve on these points to make a Pong game more on par with Atari's original Pong.