Your First Game: Pong

Now that you’ve got the example launcher up and working, let’s start from scratch and write a game. For the example, we’ll write Pong.

Note

To begin, clear out the game/ directory and follow along as we rewrite it.

In order for the Launcher to find your game, you must make the game/ directory be an importable python module by creating game/__init__.py (you can read more about Python Packaging here). The Launcher expects this file to have a main function which pushes the first Scene for the game on the Director‘s stack.

The Director and Scenes

The Director and Scenes are the most fundamental way to organize a game in Spyral. At any given time, a Scene is running and controlling the game. The Director manages movement between Scenes. The top Scene on the stack is the current Scene, and transitions require:

Your game will have many Scenes (perhaps representing a main menu, a character select screen, an individual level, or a pause menu), but there is only ever the one Director.

Our Pong game will eventually have two Scenes: a simple menu, and the actual Pong game. For now, let’s make an empty class to represent the second of those two Scenes. Then we can have the main function push that Scene onto the top of the Director’s stack. To keep our code organized, we’ll split this into multiple files.

game/__init__.py

1
2
3
4
5
import spyral
from pong import Pong

def main():
    spyral.director.push(Pong())

game/pong.py

1
2
3
4
5
import spyral


class Pong(spyral.Scene):
    pass

For now, we will only add in a stub for the Scene’s constructor (__init__). Notice how we call the constructor for the Pong classes parent (spyral.Scene) by using the super python keyword. Whenever you subclass in Python, you should call the super class in this way (More information). Scenes require a size on initialization, and all XO games should have the same size.

Note

If your monitor is not big enough to display a 1200x900 window, you can scale the resolution without affecting your game using the development launcher.

>>> .\dev-launcher.py -r 600 450

game/pong.py

1
2
3
4
5
6
7
8
9
import spyral

WIDTH = 1200
HEIGHT = 900
SIZE = (WIDTH, HEIGHT)

class Pong(spyral.Scene):
    def __init__(self):
        super(Pong, self).__init__(SIZE)

Before we can set our first scene property, we have to learn about Images.

Images

Images in spyral are the basic building blocks of drawing. They are conceptually simple, but there are many methods to manipulate them. It is worthwhile to spend some time reading the docs on Images. To make our background, we will

  • create a new image using the Image constructor, sized to the Scene,
  • assign it as the background for this Scene
  • fill this image with black, and finally,

game/pong.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import spyral

WIDTH = 1200
HEIGHT = 900
SIZE = (WIDTH, HEIGHT)

class Pong(spyral.Scene):
    def __init__(self):
        super(Pong, self).__init__(SIZE)
        
        self.background = spyral.Image(size=SIZE).fill((0, 0, 0))

Now that we have a background, we’ll want to create Images that represent the paddles and ball in Pong. For this, we’ll talk about Sprites.

Sprites

Sprites have an Image, along with some information about where and how to draw themselves. Sprites allow us to control things like positioning, scaling, rotation, and more. There are also more advanced Sprites, including ones that can do animation. For now, we’ll work with basic sprites, but you can read more about the available sprites in Sprites.

All Sprites must have an image and live in a Scene. They cannot move between Scenes, and when a Scene ends, so do the sprites. As soon as Sprites are created, they will start being drawn by the scene (you can stop them from being drawn with the visible attribute).

For now, we’ll

  • create a new Paddle sprite,
  • give the Paddle a new image (a solid rectangle),
  • create two instances of the Paddle sprites within the scene, and,
  • position the sprites close to the left and right of the screen, using the sprite’s anchor attribute to improve positioning,

game/pong.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import spyral

WIDTH = 1200
HEIGHT = 900
SIZE = (WIDTH, HEIGHT)

class Paddle(spyral.Sprite):
    def __init__(self, scene):
        super(Paddle, self).__init__(scene)
        
        self.image = spyral.Image(size=(20, 300)).fill((255, 255, 255))


class Pong(spyral.Scene):
    def __init__(self):
        super(Pong, self).__init__(SIZE)
        
        self.background = spyral.Image(size=SIZE).fill( (0, 0, 0) )

        self.left_paddle = Paddle(self)
        self.right_paddle = Paddle(self)
        
        self.left_paddle.anchor = 'midleft'
        self.left_paddle.pos = (20, HEIGHT / 2)
        
        self.right_paddle.anchor = 'midright'
        self.right_paddle.pos = (WIDTH - 20, HEIGHT / 2)
        

A good rule of thumb is to avoid manipulating sprites at the Scene level. So we’ll refactor the positioning and anchors inside the Paddle constructor.

game/pong.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import spyral

WIDTH = 1200
HEIGHT = 900
SIZE = (WIDTH, HEIGHT)

class Paddle(spyral.Sprite):
    def __init__(self, scene, side):
        super(Paddle, self).__init__(scene)
        
        self.image = spyral.Image(size=(20, 300)).fill((255, 255, 255))
        
        self.anchor = 'mid' + side
        if side == 'left':
            self.x = 20
        else:
            self.x = WIDTH - 20
        self.y = HEIGHT/2


class Pong(spyral.Scene):
    def __init__(self):
        super(Pong, self).__init__(SIZE)
        
        self.background = spyral.Image(size=SIZE).fill( (0, 0, 0) )

        self.left_paddle = Paddle(self, 'left')
        self.right_paddle = Paddle(self, 'right')

Moving the Ball

Next, we’ll add a ball, but we’ll treat it differently than the paddles. The ball is going to move on it’s own, so we’ll make a Ball class, inheriting from the Sprite class again. We already know how to position, set an image (using the draw_circle fuction), and anchor this new sprite.

game/pong.py

1
2
3
4
5
6
7
8
class Ball(spyral.Sprite):
    def __init__(self, scene):
        super(Ball, self).__init__(scene)
        
        self.image = spyral.Image(size=(20, 20))
        self.image.draw_circle((255, 255, 255), (10, 10), 10)
        self.anchor = 'center'
        self.pos = (WIDTH/2, HEIGHT/2)

To make the ball move every frame, we’ll need to register a function of the ball with the director.update event. There are many possible events (see the Event List for a complete list), and you can even make your own (as we will see later). The director.update event is the most common, however. When a method is registered with this event, the method will be called every update.

Additionally, we need to perform some math to calculate the velocity of the ball. In order to reuse this function later, and to keep our code simpler, we can move it to new method that we’ll name reset.

game/pong.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Ball(spyral.Sprite):
    def __init__(self, scene):
        super(Ball, self).__init__(scene)
        
        self.image = spyral.Image(size=(20, 20))
        self.image.draw_circle((255, 255, 255), (10, 10), 10)
        self.anchor = 'center'
        
        spyral.event.register('director.update', self.update)
        self.reset()
                
    def update(self, delta):
        self.x += delta * self.vel_x
        self.y += delta * self.vel_y

    def reset(self):
        # We'll start by picking a random angle for the ball to move
        # We repick the direction if it isn't headed for the left
        # or the right hand side
        theta = random.random()*2*math.pi
        while ((theta > math.pi/4 and theta < 3*math.pi/4) or
               (theta > 5*math.pi/4 and theta < 7*math.pi/4)):
            theta = random.random()*2*math.pi
        # In addition to an angle, we need a velocity. Let's have the
        # ball move at 300 pixels per second
        r = 300
        
        self.vel_x = r * math.cos(theta)
        self.vel_y = r * math.sin(theta)

        # We'll start the ball at the center. self.pos is actually the
        # same as accessing sprite.x and sprite.y individually
        self.pos = (WIDTH/2, HEIGHT/2)

Collision Detection

Next, we’d like to have our ball interact with the sides of the game board, and with the paddles. We’ll do two different types of collision detection here just to showcase them. Which you use will depend largely on the game.

First, we’ll have the ball bounce off the top and bottom of the screen. For this, we’ll do simple checks on the y coordinate of the ball. You may remember that we used a center anchor on the ball, so the coordinates are relative to the center of the ball. To remedy this, we’ll use the Sprite attribute rect, which gives us a rectangle that represents the drawn area of the sprite, and we can check it’s top and bottom attributes. When we see that they have passed the ceiling or the floor, we’ll flip the y component of the velocity.

game/pong.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Ball(spyral.Sprite):
    def __init__(self, scene):
        super(Ball, self).__init__(scene)
        
        self.image = spyral.Image(size=(20, 20))
        self.image.draw_circle((255, 255, 255), (10, 10), 10)
        self.anchor = 'center'
        
        spyral.event.register('director.update', self.update)
        self.reset()
        
    def update(self, delta):
        self.x += delta * self.vel_x
        self.y += delta * self.vel_y
        
        r = self.rect
        if r.top < 0:
            r.top = 0
            self.vel_y = -self.vel_y
        if r.bottom > HEIGHT:
            r.bottom = HEIGHT
            self.vel_y = -self.vel_y
        
    def reset(self):
        # We'll start by picking a random angle for the ball to move

Next, we’ll have the ball collide with the two paddles. We will place the collision check at the Scene level, because it requires checking two Sprites. Every director.update, we’ll check to see if the ball is colliding with either padel; if so, then we will call a method in the Ball class called bounce that flips the horizontal velocity of the ball. It will check for collisions using the collide_sprites method of scenes. Note that sprites also have a collide_sprite method.

game/pong.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
import spyral
import random
import math

WIDTH = 1200
HEIGHT = 900
SIZE = (WIDTH, HEIGHT)

class Ball(spyral.Sprite):
    def __init__(self, scene):
        super(Ball, self).__init__(scene)
        
        self.image = spyral.Image(size=(20, 20))
        self.image.draw_circle((255, 255, 255), (10, 10), 10)
        self.anchor = 'center'
        
        spyral.event.register('director.update', self.update)
        self.reset()
        
    def update(self, delta):
        self.x += delta * self.vel_x
        self.y += delta * self.vel_y
        
        r = self.rect
        if r.top < 0:
            r.top = 0
            self.vel_y = -self.vel_y
        if r.bottom > HEIGHT:
            r.bottom = HEIGHT
            self.vel_y = -self.vel_y
        
    def reset(self):
        # We'll start by picking a random angle for the ball to move
        # We repick the direction if it isn't headed for the left
        # or the right hand side
        theta = random.random()*2*math.pi
        while ((theta > math.pi/4 and theta < 3*math.pi/4) or
               (theta > 5*math.pi/4 and theta < 7*math.pi/4)):
            theta = random.random()*2*math.pi
        # In addition to an angle, we need a velocity. Let's have the
        # ball move at 300 pixels per second
        r = 300
        
        self.vel_x = r * math.cos(theta)
        self.vel_y = r * math.sin(theta)

        # We'll start the ball at the center. self.pos is actually the
        # same as accessing sprite.x and sprite.y individually
        self.pos = (WIDTH/2, HEIGHT/2)
    
    def bounce(self):
        self.vel_x = -self.vel_x

class Paddle(spyral.Sprite):
    def __init__(self, scene, side):
        super(Paddle, self).__init__(scene)
        
        self.image = spyral.Image(size=(20, 300)).fill((255, 255, 255))
        
        self.anchor = 'mid' + side
        if side == 'left':
            self.x = 20
        else:
            self.x = WIDTH - 20
        self.y = HEIGHT/2


class Pong(spyral.Scene):
    def __init__(self):
        super(Pong, self).__init__(SIZE)
        
        self.background = spyral.Image(size=SIZE).fill( (0, 0, 0) )

        self.left_paddle = Paddle(self, 'left')
        self.right_paddle = Paddle(self, 'right')
        self.ball = Ball(self)

        spyral.event.register("director.update", self.update)
    
    def update(self, delta):
        if (self.collide_sprites(self.ball, self.left_paddle) or
            self.collide_sprites(self.ball, self.right_paddle)):
            self.ball.bounce()

User Input

User Input is handled the same way that director.update is - by registering a function with the event. To get started, we’ll register another event on the scene: system.quit, which is fired when the user presses the exit button. Almost every game will want to respect this event.

game/pong.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Pong(spyral.Scene):
    def __init__(self):
        super(Pong, self).__init__(SIZE)
        
        self.background = spyral.Image(size=SIZE).fill( (0, 0, 0) )

        self.left_paddle = Paddle(self, 'left')
        self.right_paddle = Paddle(self, 'right')
        self.ball = Ball(self)

        spyral.event.register("director.update", self.update)
        spyral.event.register("system.quit", spyral.director.pop)
    

A much more interesting event is input.keyboard.down.*, which is fired whenever the keyboard is pressed. You can also register on specific keys, e.g., input.keyboard.down.left or input.keyboard.keyboard.down.f. A complete list of keys is available Keyboard Keys.

The left and right paddles need to move differently depending on which side they are on - the left paddle responds to w and s, and the right paddle responds to up and down. Also, we want the paddles to keep moving after the keys are released. We’ll use a moving attribute to keep track of whether the paddle should move either "up" or "down".

game/pong.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
        
        self.image = spyral.Image(size=(20, 300)).fill((255, 255, 255))
        
        self.anchor = 'mid' + side
        
        self.side = side
        self.moving = False
        self.reset()
        
        if self.side == 'left':
            spyral.event.register("input.keyboard.down.w", self.move_up)
            spyral.event.register("input.keyboard.down.s", self.move_down)
            spyral.event.register("input.keyboard.up.w", self.stop_move)
            spyral.event.register("input.keyboard.up.s", self.stop_move)
        else:
            spyral.event.register("input.keyboard.down.up", self.move_up)
            spyral.event.register("input.keyboard.down.down", self.move_down)
            spyral.event.register("input.keyboard.up.up", self.stop_move)
            spyral.event.register("input.keyboard.up.down", self.stop_move)
        spyral.event.register("director.update", self.update)
    
    def move_up(self):
        self.moving = 'up'
        
    def move_down(self):
        self.moving = 'down'
        
    def stop_move(self):
        self.moving = False
        
    def reset(self):
        if self.side == 'left':
            self.x = 20
        else:
            self.x = WIDTH - 20
        self.y = HEIGHT/2
        
    def update(self, dt):
        paddle_velocity = 250
        
        if self.moving == 'up':
            self.y -= paddle_velocity * dt
            
        elif self.moving == 'down':
            self.y += paddle_velocity * dt
                
        r = self.get_rect()
        if r.top < 0:
            r.top = 0
        if r.bottom > HEIGHT:
            r.bottom = HEIGHT
        self.pos = r.center


class Pong(spyral.Scene):

User Events

New events can be queued and registered in spyral as easily as system events. We’ll queue a new event pong.score when the ball goes either on the left or right side of the screen. Notice that we pass in a Event, which we give a parameter named scorer. Functions registered to this event can take in a scorer parameter to find out who scored.

We also register the reset method with this pong.score event on the Paddles and Ball, so that they are reset when someone scores. Finally, we register an increase_score method on the Scene, so that we can keep track of the score of the game. Notice how we have created a new model dictionary outside of the Scene; this model can hold the global state, and be saved and loaded more easily if we someday wanted to enable saving.

game/pong.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
import spyral
import random
import math

WIDTH = 1200
HEIGHT = 900
SIZE = (WIDTH, HEIGHT)

model = {"left": 0, "right": 0}

class Ball(spyral.Sprite):
    def __init__(self, scene):
        super(Ball, self).__init__(scene)
        
        self.image = spyral.Image(size=(20, 20))
        self.image.draw_circle((255, 255, 255), (10, 10), 10)
        self.anchor = 'center'
        
        spyral.event.register('director.update', self.update)
        spyral.event.register('pong.score', self.reset)
        self.reset()
        
    def update(self, delta):
        self.x += delta * self.vel_x
        self.y += delta * self.vel_y
        
        r = self.rect
        if r.top < 0:
            r.top = 0
            self.vel_y = -self.vel_y
        if r.bottom > HEIGHT:
            r.bottom = HEIGHT
            self.vel_y = -self.vel_y
        if r.left < 0:
            spyral.event.queue("pong.score", spyral.Event(scorer="left"))
        if r.right > WIDTH:
            spyral.event.queue("pong.score", spyral.Event(scorer="right"))
        
    def reset(self):
        # We'll start by picking a random angle for the ball to move
        # We repick the direction if it isn't headed for the left
        # or the right hand side
        theta = random.random()*2*math.pi
        while ((theta > math.pi/4 and theta < 3*math.pi/4) or
               (theta > 5*math.pi/4 and theta < 7*math.pi/4)):
            theta = random.random()*2*math.pi
        # In addition to an angle, we need a velocity. Let's have the
        # ball move at 300 pixels per second
        r = 300
        
        self.vel_x = r * math.cos(theta)
        self.vel_y = r * math.sin(theta)

        # We'll start the ball at the center. self.pos is actually the
        # same as accessing sprite.x and sprite.y individually
        self.pos = (WIDTH/2, HEIGHT/2)
    
    def bounce(self):
        self.vel_x = -self.vel_x

class Paddle(spyral.Sprite):
    def __init__(self, scene, side):
        super(Paddle, self).__init__(scene)
        
        self.image = spyral.Image(size=(20, 300)).fill((255, 255, 255))
        
        self.anchor = 'mid' + side
        self.side = side
        self.moving = False
        self.reset()
        
        if self.side == 'left':
            spyral.event.register("input.keyboard.down.w", self.move_up)
            spyral.event.register("input.keyboard.down.s", self.move_down)
            spyral.event.register("input.keyboard.up.w", self.stop_move)
            spyral.event.register("input.keyboard.up.s", self.stop_move)
        else:
            spyral.event.register("input.keyboard.down.up", self.move_up)
            spyral.event.register("input.keyboard.down.down", self.move_down)
            spyral.event.register("input.keyboard.up.up", self.stop_move)
            spyral.event.register("input.keyboard.up.down", self.stop_move)
        spyral.event.register("director.update", self.update)
        spyral.event.register('pong.score', self.reset)
    
    def move_up(self):
        self.moving = 'up'
        
    def move_down(self):
        self.moving = 'down'
        
    def stop_move(self):
        self.moving = False
        
    def reset(self):
        if self.side == 'left':
            self.x = 20
        else:
            self.x = WIDTH - 20
        self.y = HEIGHT/2
        
    def update(self, delta):
        paddle_velocity = 250
        
        if self.moving == 'up':
            self.y -= paddle_velocity * delta
            
        elif self.moving == 'down':
            self.y += paddle_velocity * delta
                
        r = self.rect
        if r.top < 0:
            r.top = 0
        if r.bottom > HEIGHT:
            r.bottom = HEIGHT


class Pong(spyral.Scene):
    def __init__(self):
        super(Pong, self).__init__(SIZE)
        
        self.background = spyral.Image(size=SIZE).fill( (0, 0, 0) )

        self.left_paddle = Paddle(self, 'left')
        self.right_paddle = Paddle(self, 'right')
        self.ball = Ball(self)

        spyral.event.register("director.update", self.update)
        spyral.event.register("system.quit", spyral.director.pop)
        spyral.event.register("pong.score", self.increase_score)
    
    def increase_score(self, scorer):
        model[scorer] += 1
    
    def update(self, delta):
        if (self.collide_sprites(self.ball, self.left_paddle) or
            self.collide_sprites(self.ball, self.right_paddle)):
            self.ball.bounce()