Building Pong

Play it first → Two paddles and a ball. You are on the left, the computer is on the right, and you hit the ball back and forth until one of you misses. Every hit makes the ball a little faster, and where it hits your paddle decides which way it goes. First to 7 wins.

This is how it was built, a step at a time. Every numbered step below is a whole program: copy it into the playground, press Run, and it works. Each step is the one before it with something added, so you can build the game the way it was built. Then break something on purpose and see what changes. That teaches you more than reading does.

There is nothing clever in it. No list comprehensions, no tricks, and every kaypy function it uses is on the API page. There are no pictures at all: the paddles, the ball and the net are rectangles.

If you haven't built a game yet, start with Ghost Chase and then Bomb Dodge. Then build Paddle Ball, which is this game for one player: the ball, the bouncing and the paddle check are all built up there a step at a time, so this page can go faster. Spiral Storm comes between the two, as a break from paddles.

The one big idea: the ball carries its own speed

A game object can carry values of its own. Write ball.dx = 360 and from then on the ball has a dx, just as it has a pos. Here dx is how fast the ball goes across, and dy how fast it goes down, both in pixels per second. A negative dx means it is going left; a negative dy means it is going up.

Every frame, the ball moves by its speed. Bouncing is just making one of the two speeds negative. Hit the top and dy turns round; hit a paddle and dx does. Nothing has an area() or a body(). The code works out for itself what the ball is touching.

Step 1 — the paddles

The court, and a paddle you can move. The computer's paddle is there too, but it doesn't move yet.

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 40])

PADDLE_W = 16          # how wide a paddle is
PADDLE_H = 100         # and how tall
PADDLE_GAP = 30        # how far each paddle is from its edge of the window
PADDLE_SPEED = 480     # how fast your paddle moves, in pixels per second

# The net: a dashed line down the middle. It is only a picture.
for y in range(10, height(), 40):
    add([rect(4, 20), pos(width() / 2 - 2, y), color(90, 100, 130)])

# anchor("center") means pos() is the middle of the paddle.
player = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
              pos(PADDLE_GAP, height() / 2), color(120, 220, 255)])
cpu = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
           pos(width() - PADDLE_GAP, height() / 2), color(255, 140, 120)])


@onKeyDown("up")
def move_up():
    player.move(0, -PADDLE_SPEED)
    keep_on_screen()


@onKeyDown("down")
def move_down():
    player.move(0, PADDLE_SPEED)
    keep_on_screen()


@onMouseDown
def move_to_mouse():
    if mousePos().y < player.pos.y - 10:
        player.move(0, -PADDLE_SPEED)
    if mousePos().y > player.pos.y + 10:
        player.move(0, PADDLE_SPEED)
    keep_on_screen()


def keep_on_screen():
    player.pos.y = clamp(player.pos.y, PADDLE_H / 2,
                         height() - PADDLE_H / 2)

Hold up or down and your paddle moves. Hold the mouse down somewhere and it moves towards it. On a phone, a finger does the same thing.

  • The settings come first. Names in capitals, like PADDLE_SPEED, are numbers you set once and never change while the game runs. Putting them at the top means you can find them, and change the game without hunting through it.
  • The net is a dashed line made of rectangles 4 pixels wide and 20 tall. range(10, height(), 40) counts 10, 50, 90, and so on down the window, one rectangle every 40 pixels.
  • anchor("center") makes pos() mean the middle of the paddle, not its top-left corner. Everything below is easier for it: the paddle's top is player.pos.y - PADDLE_H / 2 and its bottom is player.pos.y + PADDLE_H / 2.
  • @onKeyDown("up") runs move_up every frame the up arrow is held, about sixty times a second, so the paddle keeps moving until you let go. player.move(0, -PADDLE_SPEED) moves it up at PADDLE_SPEED pixels a second: up is negative, because y counts down from the top of the window.
  • @onMouseDown runs move_to_mouse every frame the mouse button is held, or a finger is on the screen. More than 10 pixels above the paddle's middle and the paddle moves up; more than 10 below and it moves down. Within 10 pixels counts as there.

keep_on_screen() stops the paddle at the edges. clamp(player.pos.y, PADDLE_H / 2, height() - PADDLE_H / 2) gives back player.pos.y, unless it is less than 50 (then it gives 50) or more than 550 (then it gives 550). The paddle is 100 tall, so 50 is half of it, and the paddle stops with its end touching the edge.

Something to break. Take keep_on_screen() out of move_up and hold up. There's no error message. The paddle goes off the top of the window and keeps going: three seconds of holding up took it to y = −1161, more than a thousand pixels above the window. Hold down for as long to bring it back.

Step 2 — the ball

A ball that moves by itself and bounces off the edges of the window. For now, all four edges are walls.

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 40])

PADDLE_W = 16          # how wide a paddle is
PADDLE_H = 100         # and how tall
PADDLE_GAP = 30        # how far each paddle is from its edge of the window
PADDLE_SPEED = 480     # how fast your paddle moves, in pixels per second

BALL_SIZE = 16
BALL_SPEED = 360       # how fast the ball goes, in pixels per second

# The net: a dashed line down the middle. It is only a picture.
for y in range(10, height(), 40):
    add([rect(4, 20), pos(width() / 2 - 2, y), color(90, 100, 130)])

# anchor("center") means pos() is the middle of the paddle.
player = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
              pos(PADDLE_GAP, height() / 2), color(120, 220, 255)])
cpu = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
           pos(width() - PADDLE_GAP, height() / 2), color(255, 140, 120)])

# The ball carries its own speed: dx across and dy down, in pixels per
# second. A negative dx means it is going left, a negative dy up.
ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
            pos(width() / 2, height() / 2)])
ball.dx = BALL_SPEED
ball.dy = rand(-BALL_SPEED / 2, BALL_SPEED / 2)


@onKeyDown("up")
def move_up():
    player.move(0, -PADDLE_SPEED)
    keep_on_screen()


@onKeyDown("down")
def move_down():
    player.move(0, PADDLE_SPEED)
    keep_on_screen()


@onMouseDown
def move_to_mouse():
    if mousePos().y < player.pos.y - 10:
        player.move(0, -PADDLE_SPEED)
    if mousePos().y > player.pos.y + 10:
        player.move(0, PADDLE_SPEED)
    keep_on_screen()


def keep_on_screen():
    player.pos.y = clamp(player.pos.y, PADDLE_H / 2,
                         height() - PADDLE_H / 2)


@onUpdate
def move_ball():
    ball.pos.x = ball.pos.x + ball.dx * dt()
    ball.pos.y = ball.pos.y + ball.dy * dt()

    # Off the top or the bottom: put it back on the edge, and turn it round.
    if ball.pos.y < BALL_SIZE / 2:
        ball.pos.y = BALL_SIZE / 2
        ball.dy = -ball.dy
    if ball.pos.y > height() - BALL_SIZE / 2:
        ball.pos.y = height() - BALL_SIZE / 2
        ball.dy = -ball.dy

    # The sides are walls too, for now.
    if ball.pos.x < BALL_SIZE / 2:
        ball.pos.x = BALL_SIZE / 2
        ball.dx = -ball.dx
    if ball.pos.x > width() - BALL_SIZE / 2:
        ball.pos.x = width() - BALL_SIZE / 2
        ball.dx = -ball.dx

The ball's own values. ball.dx = BALL_SPEED gives the ball a dx, 360, so it starts off to the right. rand(-BALL_SPEED / 2, BALL_SPEED / 2) picks a random dy between −180 and 180, so it goes off at a slant, up or down, and differently every time you press Run.

Moving. @onUpdate runs move_ball once a frame. ball.dx * dt() is how far the ball goes across in one frame: dt() is how long the frame took, about 1/60 of a second, so at 360 pixels a second the ball moves about 6 pixels a frame. Multiplying by dt() is what makes the ball go the same speed on a fast computer and a slow one.

Bouncing. When the ball's middle is less than half a ball from the top, it has reached the top. It is put back exactly on the edge, and ball.dy = -ball.dy turns it round: −180 becomes 180, and the ball heads down. The other three edges work the same way.

Putting it back on the edge matters more than it looks. A frame can carry the ball a few pixels past the edge. If it is turned round but left out there, and the next frame does not bring it all the way back in, it is still past the edge, so it is turned round again, the wrong way, and leaves the window. With the uneven frames a browser can have, that really happens.

Two things to break.

Take out ball.dy = -ball.dy under the top edge, and keep the line that puts the ball back. There's no error. The first time the ball reaches the top it stays there, sliding along the top edge from wall to wall for ever: it is put back on the edge every frame, but never turned round.

Take out the line ball.dx = BALL_SPEED. The game stops at once, with

AttributeError: GameObj has no attribute 'dx' — did you forget a component that provides it?

A game object only has a dx once you give it one. kaypy guesses you forgot a component, because that is usually why. Here, though, dx is a value of your own, and what's missing is the line that sets it.

Step 3 — hitting it back

The ball bounces off the paddles now, and the computer moves its paddle to meet it. The sides aren't walls any more: a ball that gets past a paddle goes off the side, and comes back from the middle going the other way.

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 40])

PADDLE_W = 16          # how wide a paddle is
PADDLE_H = 100         # and how tall
PADDLE_GAP = 30        # how far each paddle is from its edge of the window
PADDLE_SPEED = 480     # how fast your paddle moves, in pixels per second

BALL_SIZE = 16
BALL_SPEED = 360       # how fast the ball goes, in pixels per second
BALL_ANGLE = 0.75      # how steeply the ends of a paddle send the ball

CPU_SPEED = 300        # how fast the computer's paddle can move

# The net: a dashed line down the middle. It is only a picture.
for y in range(10, height(), 40):
    add([rect(4, 20), pos(width() / 2 - 2, y), color(90, 100, 130)])

# anchor("center") means pos() is the middle of the paddle.
player = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
              pos(PADDLE_GAP, height() / 2), color(120, 220, 255)])
cpu = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
           pos(width() - PADDLE_GAP, height() / 2), color(255, 140, 120)])

# The ball carries its own speed: dx across and dy down, in pixels per
# second. A negative dx means it is going left, a negative dy up.
ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
            pos(width() / 2, height() / 2)])
ball.dx = BALL_SPEED
ball.dy = rand(-BALL_SPEED / 2, BALL_SPEED / 2)


@onKeyDown("up")
def move_up():
    player.move(0, -PADDLE_SPEED)
    keep_on_screen()


@onKeyDown("down")
def move_down():
    player.move(0, PADDLE_SPEED)
    keep_on_screen()


@onMouseDown
def move_to_mouse():
    if mousePos().y < player.pos.y - 10:
        player.move(0, -PADDLE_SPEED)
    if mousePos().y > player.pos.y + 10:
        player.move(0, PADDLE_SPEED)
    keep_on_screen()


def keep_on_screen():
    player.pos.y = clamp(player.pos.y, PADDLE_H / 2,
                         height() - PADDLE_H / 2)


@onUpdate
def computer():
    # The computer follows the ball, but only when the ball is coming its
    # way, and never faster than CPU_SPEED.
    if ball.dx > 0:
        target = ball.pos.y
    else:
        target = height() / 2       # drift back to the middle
    if target < cpu.pos.y - 10:
        cpu.move(0, -CPU_SPEED)
    if target > cpu.pos.y + 10:
        cpu.move(0, CPU_SPEED)


@onUpdate
def move_ball():
    # Where the ball is before it moves, to see what it crossed.
    old_x = ball.pos.x
    ball.pos.x = ball.pos.x + ball.dx * dt()
    ball.pos.y = ball.pos.y + ball.dy * dt()

    # Off the top or the bottom: put it back on the edge, and turn it round.
    if ball.pos.y < BALL_SIZE / 2:
        ball.pos.y = BALL_SIZE / 2
        ball.dy = -ball.dy
    if ball.pos.y > height() - BALL_SIZE / 2:
        ball.pos.y = height() - BALL_SIZE / 2
        ball.dy = -ball.dy

    # Did it just cross the front of your paddle, going left?
    edge = player.pos.x + PADDLE_W / 2 + BALL_SIZE / 2
    if old_x >= edge and ball.pos.x < edge and touching(player):
        ball.pos.x = edge
        bounce(player, 1)

    # The front of the computer's paddle, going right.
    edge = cpu.pos.x - PADDLE_W / 2 - BALL_SIZE / 2
    if old_x <= edge and ball.pos.x > edge and touching(cpu):
        ball.pos.x = edge
        bounce(cpu, -1)

    # Gone off the side: back to the middle, going the other way.
    if ball.pos.x < -BALL_SIZE or ball.pos.x > width() + BALL_SIZE:
        ball.pos = vec2(width() / 2, height() / 2)
        ball.dx = -ball.dx


def touching(paddle):
    # Is the ball level with the paddle? Half the paddle and half the ball,
    # up or down from the paddle's middle.
    return abs(ball.pos.y - paddle.pos.y) < PADDLE_H / 2 + BALL_SIZE / 2


def bounce(paddle, direction):
    # direction is 1 to send the ball right, -1 to send it left.
    ball.dx = BALL_SPEED * direction

    # Where on the paddle it hit: -1 is the top end, 0 the middle, 1 the
    # bottom end. That decides how steeply it comes off.
    where = (ball.pos.y - paddle.pos.y) / (PADDLE_H / 2)
    where = clamp(where, -1, 1)
    ball.dy = where * BALL_SPEED * BALL_ANGLE

Did the ball reach the paddle? The front of your paddle is its middle plus half its width. The ball's middle touches it at half a ball further on: that is edge, 46 pixels from the left of the window. The ball moves 6 pixels or so a frame, so it is hardly ever exactly at edge. So the question is whether it crossed the edge this frame. old_x is where the ball was before it moved. If it was in front of edge before and is behind it now, it has just crossed.

Was the paddle there? touching() asks whether the ball is level with the paddle: whether the distance between their middles, up and down, is less than half the paddle plus half the ball. It gives back True or False, which is why it can go straight into an if.

Where it hits decides where it goes. bounce() turns the ball round, then works out where: how far the ball is from the paddle's middle, divided by half the paddle. In the middle that is 0; at the top end, −1; at the bottom end, 1. A ball hitting 40 pixels below the middle gets where = 0.8, and goes off downwards at 0.8 * 360 * 0.75, 216 pixels a second. Hitting the ball with the end of the paddle is how you aim it. clamp keeps where between −1 and 1, because the ball can touch the paddle with its edge, a little beyond the paddle's end.

The computer moves its paddle towards the ball, but only when the ball is coming its way; otherwise it drifts back to the middle. It can never move faster than CPU_SPEED, 300 pixels a second. For now that is plenty: the steepest the ball can go is 0.75 * 360, 270 pixels a second up or down. In step 5 the ball gets faster, and then a steep one can get away from it.

Something to break. In move_ball, change bounce(player, 1) to bounce(player, -1), so the ball is sent left instead of right. There's no error. The ball reaches your paddle and stops dead, stuck to the front of it. Every frame it moves left, crosses edge, and is put back on it, still going left. Move the paddle away and the ball is let go: it carries on off the side.

Step 4 — points, and serving

A ball that goes off the side is a point to the player at the other end. The score goes at the top, and after each point the ball waits in the middle for a second, then is served towards whoever lost it.

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 40])

PADDLE_W = 16          # how wide a paddle is
PADDLE_H = 100         # and how tall
PADDLE_GAP = 30        # how far each paddle is from its edge of the window
PADDLE_SPEED = 480     # how fast your paddle moves, in pixels per second

BALL_SIZE = 16
BALL_SPEED = 360       # how fast the ball goes, in pixels per second
BALL_ANGLE = 0.75      # how steeply the ends of a paddle send the ball

CPU_SPEED = 300        # how fast the computer's paddle can move

player_score = 0
cpu_score = 0

# The net: a dashed line down the middle. It is only a picture.
for y in range(10, height(), 40):
    add([rect(4, 20), pos(width() / 2 - 2, y), color(90, 100, 130)])

# anchor("center") means pos() is the middle of the paddle.
player = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
              pos(PADDLE_GAP, height() / 2), color(120, 220, 255)])
cpu = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
           pos(width() - PADDLE_GAP, height() / 2), color(255, 140, 120)])

# The ball carries its own speed: dx across and dy down, in pixels per
# second. A negative dx means it is going left, a negative dy up.
ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
            pos(width() / 2, height() / 2)])
ball.dx = 0
ball.dy = 0

player_label = add([text("0", size=56), pos(width() / 2 - 60, 50),
                    anchor("center")])
cpu_label = add([text("0", size=56), pos(width() / 2 + 60, 50),
                 anchor("center")])


@onKeyDown("up")
def move_up():
    player.move(0, -PADDLE_SPEED)
    keep_on_screen()


@onKeyDown("down")
def move_down():
    player.move(0, PADDLE_SPEED)
    keep_on_screen()


@onMouseDown
def move_to_mouse():
    if mousePos().y < player.pos.y - 10:
        player.move(0, -PADDLE_SPEED)
    if mousePos().y > player.pos.y + 10:
        player.move(0, PADDLE_SPEED)
    keep_on_screen()


def keep_on_screen():
    player.pos.y = clamp(player.pos.y, PADDLE_H / 2,
                         height() - PADDLE_H / 2)


@onUpdate
def computer():
    # The computer follows the ball, but only when the ball is coming its
    # way, and never faster than CPU_SPEED.
    if ball.dx > 0:
        target = ball.pos.y
    else:
        target = height() / 2       # drift back to the middle
    if target < cpu.pos.y - 10:
        cpu.move(0, -CPU_SPEED)
    if target > cpu.pos.y + 10:
        cpu.move(0, CPU_SPEED)


def serve(direction):
    # direction is 1 to serve to the right, -1 to the left. The ball starts
    # in the middle and goes off at a random slant.
    ball.pos = vec2(width() / 2, height() / 2)
    ball.dx = BALL_SPEED * direction
    ball.dy = rand(-BALL_SPEED / 2, BALL_SPEED / 2)


# wait() runs a function later, and it has to be one that needs nothing
# handed to it. So there is one for each way the ball can be served.
def serve_left():
    serve(-1)


def serve_right():
    serve(1)


# The first serve goes towards you, a second and a half in.
wait(1.5, serve_left)


@onUpdate
def move_ball():
    # Where the ball is before it moves, to see what it crossed.
    old_x = ball.pos.x
    ball.pos.x = ball.pos.x + ball.dx * dt()
    ball.pos.y = ball.pos.y + ball.dy * dt()

    # Off the top or the bottom: put it back on the edge, and turn it round.
    if ball.pos.y < BALL_SIZE / 2:
        ball.pos.y = BALL_SIZE / 2
        ball.dy = -ball.dy
    if ball.pos.y > height() - BALL_SIZE / 2:
        ball.pos.y = height() - BALL_SIZE / 2
        ball.dy = -ball.dy

    # Did it just cross the front of your paddle, going left?
    edge = player.pos.x + PADDLE_W / 2 + BALL_SIZE / 2
    if old_x >= edge and ball.pos.x < edge and touching(player):
        ball.pos.x = edge
        bounce(player, 1)

    # The front of the computer's paddle, going right.
    edge = cpu.pos.x - PADDLE_W / 2 - BALL_SIZE / 2
    if old_x <= edge and ball.pos.x > edge and touching(cpu):
        ball.pos.x = edge
        bounce(cpu, -1)

    # Gone off the side: a point to whoever is at the other end.
    if ball.pos.x < -BALL_SIZE:
        point_to("cpu")
    if ball.pos.x > width() + BALL_SIZE:
        point_to("player")


def touching(paddle):
    # Is the ball level with the paddle? Half the paddle and half the ball,
    # up or down from the paddle's middle.
    return abs(ball.pos.y - paddle.pos.y) < PADDLE_H / 2 + BALL_SIZE / 2


def bounce(paddle, direction):
    # direction is 1 to send the ball right, -1 to send it left.
    ball.dx = BALL_SPEED * direction

    # Where on the paddle it hit: -1 is the top end, 0 the middle, 1 the
    # bottom end. That decides how steeply it comes off.
    where = (ball.pos.y - paddle.pos.y) / (PADDLE_H / 2)
    where = clamp(where, -1, 1)
    ball.dy = where * BALL_SPEED * BALL_ANGLE



def point_to(who):
    global player_score, cpu_score

    # Park the ball in the middle, standing still, until the next serve.
    ball.pos = vec2(width() / 2, height() / 2)
    ball.dx = 0
    ball.dy = 0

    if who == "player":
        player_score = player_score + 1
        player_label.text = str(player_score)
        wait(1, serve_right)
    else:
        cpu_score = cpu_score + 1
        cpu_label.text = str(cpu_score)
        wait(1, serve_left)

Scoring. point_to("cpu") or point_to("player") parks the ball in the middle with both its speeds at 0, adds one to the right score, and puts the new number on the screen. str() turns the number into text, because a text() object has to be given text.

point_to changes player_score and cpu_score, so it says global first. They were made outside the function, and a function that changes a variable from outside itself has to say so. Without it, Python decides they are new variables belonging to point_to, with no value yet. Leave it out and nothing goes wrong until the first point. If you don't move, that's about two and a half seconds in, and then:

UnboundLocalError: cannot access local variable 'cpu_score' where it is not associated with a value

Serving. serve(direction) puts the ball in the middle and sends it off: direction is 1 for right, −1 for left. wait(1, serve_right) runs serve_right a second from now. wait() wants a function it can run later with nothing handed to it, and serve needs to be told which way. So there are two little functions, serve_left and serve_right, which each call serve the right way.

Something to break. Put brackets after serve_left in the first serve: wait(1.5, serve_left()). There's no error message, which makes it a hard mistake to spot. The ball goes the moment you press Run, with no pause at all. Without brackets, wait() is handed the function, to run in 1.5 seconds. With them, Python runs serve_left right now, to find out what to hand over, and the ball is served then and there. wait() is handed what serve_left gave back, which is nothing, and has nothing left to do when the time comes.

Step 5 — faster every hit

A long rally gets exciting: every hit makes the ball a little faster. BALL_SPEED becomes three settings.

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 40])

PADDLE_W = 16          # how wide a paddle is
PADDLE_H = 100         # and how tall
PADDLE_GAP = 30        # how far each paddle is from its edge of the window
PADDLE_SPEED = 480     # how fast your paddle moves, in pixels per second

BALL_SIZE = 16
BALL_START = 360       # how fast the ball goes at the start of each point
BALL_GAIN = 25         # ...how much faster every hit makes it
BALL_FASTEST = 750     # ...and the fastest it ever goes
BALL_ANGLE = 0.75      # how steeply the ends of a paddle send the ball

CPU_SPEED = 300        # how fast the computer's paddle can move

player_score = 0
cpu_score = 0

# The net: a dashed line down the middle. It is only a picture.
for y in range(10, height(), 40):
    add([rect(4, 20), pos(width() / 2 - 2, y), color(90, 100, 130)])

# anchor("center") means pos() is the middle of the paddle.
player = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
              pos(PADDLE_GAP, height() / 2), color(120, 220, 255)])
cpu = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
           pos(width() - PADDLE_GAP, height() / 2), color(255, 140, 120)])

# The ball carries its own speed: dx across and dy down, in pixels per
# second. A negative dx means it is going left, a negative dy up.
ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
            pos(width() / 2, height() / 2)])
ball.dx = 0
ball.dy = 0

player_label = add([text("0", size=56), pos(width() / 2 - 60, 50),
                    anchor("center")])
cpu_label = add([text("0", size=56), pos(width() / 2 + 60, 50),
                 anchor("center")])


@onKeyDown("up")
def move_up():
    player.move(0, -PADDLE_SPEED)
    keep_on_screen()


@onKeyDown("down")
def move_down():
    player.move(0, PADDLE_SPEED)
    keep_on_screen()


@onMouseDown
def move_to_mouse():
    if mousePos().y < player.pos.y - 10:
        player.move(0, -PADDLE_SPEED)
    if mousePos().y > player.pos.y + 10:
        player.move(0, PADDLE_SPEED)
    keep_on_screen()


def keep_on_screen():
    player.pos.y = clamp(player.pos.y, PADDLE_H / 2,
                         height() - PADDLE_H / 2)


@onUpdate
def computer():
    # The computer follows the ball, but only when the ball is coming its
    # way, and never faster than CPU_SPEED.
    if ball.dx > 0:
        target = ball.pos.y
    else:
        target = height() / 2       # drift back to the middle
    if target < cpu.pos.y - 10:
        cpu.move(0, -CPU_SPEED)
    if target > cpu.pos.y + 10:
        cpu.move(0, CPU_SPEED)


def serve(direction):
    # direction is 1 to serve to the right, -1 to the left. The ball starts
    # in the middle and goes off at a random slant.
    ball.pos = vec2(width() / 2, height() / 2)
    ball.dx = BALL_START * direction
    ball.dy = rand(-BALL_START / 2, BALL_START / 2)


# wait() runs a function later, and it has to be one that needs nothing
# handed to it. So there is one for each way the ball can be served.
def serve_left():
    serve(-1)


def serve_right():
    serve(1)


# The first serve goes towards you, a second and a half in.
wait(1.5, serve_left)


@onUpdate
def move_ball():
    # Where the ball is before it moves, to see what it crossed.
    old_x = ball.pos.x
    ball.pos.x = ball.pos.x + ball.dx * dt()
    ball.pos.y = ball.pos.y + ball.dy * dt()

    # Off the top or the bottom: put it back on the edge, and turn it round.
    if ball.pos.y < BALL_SIZE / 2:
        ball.pos.y = BALL_SIZE / 2
        ball.dy = -ball.dy
    if ball.pos.y > height() - BALL_SIZE / 2:
        ball.pos.y = height() - BALL_SIZE / 2
        ball.dy = -ball.dy

    # Did it just cross the front of your paddle, going left?
    edge = player.pos.x + PADDLE_W / 2 + BALL_SIZE / 2
    if old_x >= edge and ball.pos.x < edge and touching(player):
        ball.pos.x = edge
        bounce(player, 1)

    # The front of the computer's paddle, going right.
    edge = cpu.pos.x - PADDLE_W / 2 - BALL_SIZE / 2
    if old_x <= edge and ball.pos.x > edge and touching(cpu):
        ball.pos.x = edge
        bounce(cpu, -1)

    # Gone off the side: a point to whoever is at the other end.
    if ball.pos.x < -BALL_SIZE:
        point_to("cpu")
    if ball.pos.x > width() + BALL_SIZE:
        point_to("player")


def touching(paddle):
    # Is the ball level with the paddle? Half the paddle and half the ball,
    # up or down from the paddle's middle.
    return abs(ball.pos.y - paddle.pos.y) < PADDLE_H / 2 + BALL_SIZE / 2


def bounce(paddle, direction):
    # direction is 1 to send the ball right, -1 to send it left. A little
    # faster each hit, up to BALL_FASTEST.
    speed = abs(ball.dx) + BALL_GAIN
    if speed > BALL_FASTEST:
        speed = BALL_FASTEST
    ball.dx = speed * direction

    # Where on the paddle it hit: -1 is the top end, 0 the middle, 1 the
    # bottom end. That decides how steeply it comes off.
    where = (ball.pos.y - paddle.pos.y) / (PADDLE_H / 2)
    where = clamp(where, -1, 1)
    ball.dy = where * speed * BALL_ANGLE



def point_to(who):
    global player_score, cpu_score

    # Park the ball in the middle, standing still, until the next serve.
    ball.pos = vec2(width() / 2, height() / 2)
    ball.dx = 0
    ball.dy = 0

    if who == "player":
        player_score = player_score + 1
        player_label.text = str(player_score)
        wait(1, serve_right)
    else:
        cpu_score = cpu_score + 1
        cpu_label.text = str(cpu_score)
        wait(1, serve_left)

Each point starts at BALL_START, 360 pixels a second. In bounce, abs(ball.dx) is how fast the ball is going across, without the minus sign that says it is going left. Every hit adds BALL_GAIN, 25, so after 10 hits the ball is going 610. The if stops it at BALL_FASTEST, 750, which it reaches on the 16th hit. Without that, a long enough rally would get too fast to see.

The angle uses the new speed too, so a fast ball comes off the end of a paddle steeply enough to beat the computer, which is still limited to 300 pixels a second.

Something to break. Leave out the abs: speed = ball.dx + BALL_GAIN. There's no error. The ball reaches your paddle and then creeps away from it, at 15 pixels a second. Going left, dx is −360, so speed is −335, and the ball is sent off at −335: still going left. Next frame it crosses edge again and bounces again, −310, and again, and again, 25 at a time, until dx gets past 0 to 15. abs() is what keeps the minus sign out of it.

Step 6 — winning, and playing again

First to 7 wins. Then press space or click to play again.

Playing again means building everything fresh: new scores, a new ball, and no wait() from the last game still waiting to serve. That's what a scene is for. Everything from the net down moves inside @scene("game"), one step further in, and go("game") at the bottom builds it. Calling go("game") again throws the whole scene away, every object, handler and wait(), and builds it again.

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 40])

PADDLE_W = 16          # how wide a paddle is
PADDLE_H = 100         # and how tall
PADDLE_GAP = 30        # how far each paddle is from its edge of the window
PADDLE_SPEED = 480     # how fast your paddle moves, in pixels per second

CPU_SPEED = 300        # how fast the computer's paddle can move

BALL_SIZE = 16
BALL_START = 360       # how fast the ball goes at the start of each point
BALL_GAIN = 25         # ...how much faster every hit makes it
BALL_FASTEST = 750     # ...and the fastest it ever goes
BALL_ANGLE = 0.75      # how steeply the ends of a paddle send the ball

WIN_SCORE = 7          # first to this many points wins

player_score = 0
cpu_score = 0
playing = True


@scene("game")
def game():
    global player_score, cpu_score, playing

    # A new game, so everything starts again.
    player_score = 0
    cpu_score = 0
    playing = True

    # The net: a dashed line down the middle. It is only a picture.
    for y in range(10, height(), 40):
        add([rect(4, 20), pos(width() / 2 - 2, y), color(90, 100, 130)])

    # anchor("center") means pos() is the middle of the paddle, which makes
    # "is the ball within the paddle?" easy to work out below.
    player = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
                  pos(PADDLE_GAP, height() / 2), color(120, 220, 255)])
    cpu = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
               pos(width() - PADDLE_GAP, height() / 2), color(255, 140, 120)])

    # The ball carries its own speed: dx across and dy down, in pixels per
    # second. A negative dx means it is going left, a negative dy up.
    ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
                pos(width() / 2, height() / 2)])
    ball.dx = 0
    ball.dy = 0

    player_label = add([text("0", size=56), pos(width() / 2 - 60, 50),
                        anchor("center")])
    cpu_label = add([text("0", size=56), pos(width() / 2 + 60, 50),
                     anchor("center")])

    # The message for the end of the game. width=560 wraps a long one onto
    # more than one line.
    message = add([text("", size=26, width=560),
                   pos(width() / 2, height() - 120), anchor("center"),
                   z(10)])

    # -------------------------------------------------------- your paddle
    #
    # onKeyDown runs its function every frame the key is held, so the
    # paddle keeps moving until you let go.

    @onKeyDown("up")
    def move_up():
        player.move(0, -PADDLE_SPEED)
        keep_on_screen()

    @onKeyDown("down")
    def move_down():
        player.move(0, PADDLE_SPEED)
        keep_on_screen()

    # Holding the mouse down -- or a finger, on a phone -- moves the paddle
    # towards it. Within 10 pixels counts as there, and the paddle stops.
    @onMouseDown
    def move_to_mouse():
        if mousePos().y < player.pos.y - 10:
            player.move(0, -PADDLE_SPEED)
        if mousePos().y > player.pos.y + 10:
            player.move(0, PADDLE_SPEED)
        keep_on_screen()

    def keep_on_screen():
        player.pos.y = clamp(player.pos.y, PADDLE_H / 2,
                             height() - PADDLE_H / 2)

    # ------------------------------------------------- the computer's paddle
    #
    # The computer follows the ball, but only when the ball is coming its
    # way, and never faster than CPU_SPEED. That is what makes it possible
    # to beat: a fast ball at a steep angle gets past it.

    @onUpdate
    def computer():
        if ball.dx > 0:
            target = ball.pos.y
        else:
            target = height() / 2       # drift back to the middle
        if target < cpu.pos.y - 10:
            cpu.move(0, -CPU_SPEED)
        if target > cpu.pos.y + 10:
            cpu.move(0, CPU_SPEED)

    # ------------------------------------------------------------- serving

    def serve(direction):
        # direction is 1 to serve to the right, -1 to the left. The ball
        # starts in the middle and goes off at a random slant.
        ball.pos = vec2(width() / 2, height() / 2)
        ball.dx = BALL_START * direction
        ball.dy = rand(-BALL_START / 2, BALL_START / 2)

    # wait() runs a function later, and it has to be one that needs nothing
    # handed to it. So there is one for each way the ball can be served.
    def serve_left():
        serve(-1)

    def serve_right():
        serve(1)

    # The first serve goes towards you, a second and a half in.
    wait(1.5, serve_left)

    # ------------------------------------------------------------ the ball

    @onUpdate
    def move_ball():
        # Where the ball is before it moves, to see what it crossed.
        old_x = ball.pos.x
        ball.pos.x = ball.pos.x + ball.dx * dt()
        ball.pos.y = ball.pos.y + ball.dy * dt()

        # Off the top or the bottom: turn it round. Putting it back on the
        # edge matters. A slow frame can carry the ball well past it, and if
        # the next frame does not bring it back inside, it is turned round
        # again -- the wrong way -- and leaves the window.
        if ball.pos.y < BALL_SIZE / 2:
            ball.pos.y = BALL_SIZE / 2
            ball.dy = -ball.dy
        if ball.pos.y > height() - BALL_SIZE / 2:
            ball.pos.y = height() - BALL_SIZE / 2
            ball.dy = -ball.dy

        # Did it just cross the front of your paddle, going left? "Just
        # crossed" -- it was on one side before it moved and is on the other
        # now -- is what matters. The ball moves 6 to 12 pixels a frame, and
        # more on a slow one, so it is hardly ever exactly AT the front.
        edge = player.pos.x + PADDLE_W / 2 + BALL_SIZE / 2
        if old_x >= edge and ball.pos.x < edge and touching(player):
            ball.pos.x = edge
            bounce(player, 1)

        # The front of the computer's paddle, going right.
        edge = cpu.pos.x - PADDLE_W / 2 - BALL_SIZE / 2
        if old_x <= edge and ball.pos.x > edge and touching(cpu):
            ball.pos.x = edge
            bounce(cpu, -1)

        # Gone off the side: a point to whoever is at the other end.
        if ball.pos.x < -BALL_SIZE:
            point_to("cpu")
        if ball.pos.x > width() + BALL_SIZE:
            point_to("player")

    def touching(paddle):
        # Is the ball level with the paddle? Half the paddle and half the
        # ball, up or down from the paddle's middle.
        return abs(ball.pos.y - paddle.pos.y) < PADDLE_H / 2 + BALL_SIZE / 2

    def bounce(paddle, direction):
        # A little faster each hit, up to BALL_FASTEST.
        speed = abs(ball.dx) + BALL_GAIN
        if speed > BALL_FASTEST:
            speed = BALL_FASTEST
        ball.dx = speed * direction

        # Where on the paddle it hit: -1 is the top end, 0 the middle, 1 the
        # bottom end. That decides how steeply it comes off.
        where = (ball.pos.y - paddle.pos.y) / (PADDLE_H / 2)
        where = clamp(where, -1, 1)
        ball.dy = where * speed * BALL_ANGLE

    # ------------------------------------------------------------ scoring

    def point_to(who):
        global player_score, cpu_score, playing

        # Park the ball in the middle, standing still, until the next serve.
        ball.pos = vec2(width() / 2, height() / 2)
        ball.dx = 0
        ball.dy = 0

        if who == "player":
            player_score = player_score + 1
            player_label.text = str(player_score)
        else:
            cpu_score = cpu_score + 1
            cpu_label.text = str(cpu_score)

        if player_score == WIN_SCORE or cpu_score == WIN_SCORE:
            playing = False
            ball.destroy()
            if player_score == WIN_SCORE:
                message.text = "You win! Press SPACE or click to play again"
            else:
                message.text = ("The computer wins. Press SPACE or click "
                                "to play again")
            return

        # The next serve goes towards whoever just lost the point.
        if who == "player":
            wait(1, serve_right)
        else:
            wait(1, serve_left)

    # Once the game is over, space or a click starts a new one.
    def again():
        if not playing:
            go("game")

    onKeyPress("space", again)
    onClick(again)


go("game")

The variables live outside the scene. player_score, cpu_score and playing are made at the top, and the scene sets them back to their starting values every time it is built. It changes them, so it says global first, like point_to.

The end of a game. When either score reaches WIN_SCORE, point_to sets playing to False, destroys the ball, and says who won. The return leaves point_to straight away, before it can arrange another serve.

again only starts a new game when playing is False. That matters more here than in most games, because you play this one with the mouse.

Something to break. Take the if not playing: out of again, so it always runs go("game"). There's no error. Then, in the middle of a game, hold the mouse down to move your paddle. Pressing the button is a click, so the game starts again from 0–0, every time you go to move.

Step 7 — the whole game

This is the game exactly as it is on the games page. It adds three things to step 6:

  • Sounds: a beep when the ball hits a paddle, a smaller one off the top and bottom, a thump for a point, and a rising beep at the end. Each is loadSound once at the top and play() where it happens.
  • A message at the start saying how to move, which goes away when the first point is scored.
  • Comments that explain why each part is there, and the settings gathered into one block near the top.
"""Pong -- the first video game anyone played at home. You against the
computer.

    up and down arrows      move your paddle
    or hold the mouse (or a finger) where you want the paddle to go

Your paddle is on the left. Hit the ball back past the computer's paddle to
score a point. Every hit makes the ball a little faster, and where it hits
your paddle decides where it goes: the middle sends it straight, the ends
send it off at an angle. First to 7 wins.

HOW THIS FILE IS PUT TOGETHER

    1. the window and the sounds
    2. the settings
    3. the game scene: the paddles, the ball, bouncing, scoring
    4. start the game

There are no pictures at all. The paddles, the ball and the net are
rectangles, and the ball is a game object that carries its own speed.
"""

from kaypy import *

# ----------------------------------------------------------------- 1. setup

kaypy(width=800, height=600, background=[20, 24, 40])

loadSound("hit", "sounds/beep.wav")
loadSound("wall", "sounds/small_beep.wav")
loadSound("point", "sounds/thump.wav")
loadSound("win", "sounds/rising_beep.wav")


# -------------------------------------------------------------- 2. settings
#
# Everything you would want to change is here. Change one at a time and
# play it.

PADDLE_W = 16          # how wide a paddle is
PADDLE_H = 100         # and how tall
PADDLE_GAP = 30        # how far each paddle is from its edge of the window
PADDLE_SPEED = 480     # how fast your paddle moves, in pixels per second

CPU_SPEED = 300        # how fast the computer's paddle can move

BALL_SIZE = 16
BALL_START = 360       # how fast the ball goes at the start of each point
BALL_GAIN = 25         # ...how much faster every hit makes it
BALL_FASTEST = 750     # ...and the fastest it ever goes
BALL_ANGLE = 0.75      # how steeply the ends of a paddle send the ball

WIN_SCORE = 7          # first to this many points wins


# ------------------------------------------------------- 3. the game scene
#
# These change while you play. A function that CHANGES one has to say
# `global` first, which means "the one up here, not a new one of my own".

player_score = 0
cpu_score = 0
playing = True


@scene("game")
def game():
    global player_score, cpu_score, playing

    # A new game, so everything starts again.
    player_score = 0
    cpu_score = 0
    playing = True

    # The net: a dashed line down the middle. It is only a picture.
    for y in range(10, height(), 40):
        add([rect(4, 20), pos(width() / 2 - 2, y), color(90, 100, 130)])

    # anchor("center") means pos() is the middle of the paddle, which makes
    # "is the ball within the paddle?" easy to work out below.
    player = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
                  pos(PADDLE_GAP, height() / 2), color(120, 220, 255)])
    cpu = add([rect(PADDLE_W, PADDLE_H), anchor("center"),
               pos(width() - PADDLE_GAP, height() / 2), color(255, 140, 120)])

    # The ball carries its own speed: dx across and dy down, in pixels per
    # second. A negative dx means it is going left, a negative dy up.
    ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
                pos(width() / 2, height() / 2)])
    ball.dx = 0
    ball.dy = 0

    player_label = add([text("0", size=56), pos(width() / 2 - 60, 50),
                        anchor("center")])
    cpu_label = add([text("0", size=56), pos(width() / 2 + 60, 50),
                     anchor("center")])

    # width=560 wraps a long message onto more than one line.
    message = add([text("Up and down arrows, or hold the mouse where you "
                        "want your paddle. First to " + str(WIN_SCORE)
                        + " wins!", size=26, width=560),
                   pos(width() / 2, height() - 120), anchor("center"),
                   z(10)])

    # -------------------------------------------------------- your paddle
    #
    # onKeyDown runs its function every frame the key is held, so the
    # paddle keeps moving until you let go.

    @onKeyDown("up")
    def move_up():
        player.move(0, -PADDLE_SPEED)
        keep_on_screen()

    @onKeyDown("down")
    def move_down():
        player.move(0, PADDLE_SPEED)
        keep_on_screen()

    # Holding the mouse down -- or a finger, on a phone -- moves the paddle
    # towards it. Within 10 pixels counts as there, and the paddle stops.
    @onMouseDown
    def move_to_mouse():
        if mousePos().y < player.pos.y - 10:
            player.move(0, -PADDLE_SPEED)
        if mousePos().y > player.pos.y + 10:
            player.move(0, PADDLE_SPEED)
        keep_on_screen()

    def keep_on_screen():
        player.pos.y = clamp(player.pos.y, PADDLE_H / 2,
                             height() - PADDLE_H / 2)

    # ------------------------------------------------- the computer's paddle
    #
    # The computer follows the ball, but only when the ball is coming its
    # way, and never faster than CPU_SPEED. That is what makes it possible
    # to beat: a fast ball at a steep angle gets past it.

    @onUpdate
    def computer():
        if ball.dx > 0:
            target = ball.pos.y
        else:
            target = height() / 2       # drift back to the middle
        if target < cpu.pos.y - 10:
            cpu.move(0, -CPU_SPEED)
        if target > cpu.pos.y + 10:
            cpu.move(0, CPU_SPEED)

    # ------------------------------------------------------------- serving

    def serve(direction):
        # direction is 1 to serve to the right, -1 to the left. The ball
        # starts in the middle and goes off at a random slant.
        ball.pos = vec2(width() / 2, height() / 2)
        ball.dx = BALL_START * direction
        ball.dy = rand(-BALL_START / 2, BALL_START / 2)

    # wait() runs a function later, and it has to be one that needs nothing
    # handed to it. So there is one for each way the ball can be served.
    def serve_left():
        serve(-1)

    def serve_right():
        serve(1)

    # The first serve goes towards you, a second and a half in.
    wait(1.5, serve_left)

    # ------------------------------------------------------------ the ball

    @onUpdate
    def move_ball():
        # Where the ball is before it moves, to see what it crossed.
        old_x = ball.pos.x
        ball.pos.x = ball.pos.x + ball.dx * dt()
        ball.pos.y = ball.pos.y + ball.dy * dt()

        # Off the top or the bottom: turn it round. Putting it back on the
        # edge matters. A slow frame can carry the ball well past it, and if
        # the next frame does not bring it back inside, it is turned round
        # again -- the wrong way -- and leaves the window.
        if ball.pos.y < BALL_SIZE / 2:
            ball.pos.y = BALL_SIZE / 2
            ball.dy = -ball.dy
            play("wall")
        if ball.pos.y > height() - BALL_SIZE / 2:
            ball.pos.y = height() - BALL_SIZE / 2
            ball.dy = -ball.dy
            play("wall")

        # Did it just cross the front of your paddle, going left? "Just
        # crossed" -- it was on one side before it moved and is on the other
        # now -- is what matters. The ball moves 6 to 12 pixels a frame, and
        # more on a slow one, so it is hardly ever exactly AT the front.
        edge = player.pos.x + PADDLE_W / 2 + BALL_SIZE / 2
        if old_x >= edge and ball.pos.x < edge and touching(player):
            ball.pos.x = edge
            bounce(player, 1)

        # The front of the computer's paddle, going right.
        edge = cpu.pos.x - PADDLE_W / 2 - BALL_SIZE / 2
        if old_x <= edge and ball.pos.x > edge and touching(cpu):
            ball.pos.x = edge
            bounce(cpu, -1)

        # Gone off the side: a point to whoever is at the other end.
        if ball.pos.x < -BALL_SIZE:
            point_to("cpu")
        if ball.pos.x > width() + BALL_SIZE:
            point_to("player")

    def touching(paddle):
        # Is the ball level with the paddle? Half the paddle and half the
        # ball, up or down from the paddle's middle.
        return abs(ball.pos.y - paddle.pos.y) < PADDLE_H / 2 + BALL_SIZE / 2

    def bounce(paddle, direction):
        # A little faster each hit, up to BALL_FASTEST.
        speed = abs(ball.dx) + BALL_GAIN
        if speed > BALL_FASTEST:
            speed = BALL_FASTEST
        ball.dx = speed * direction

        # Where on the paddle it hit: -1 is the top end, 0 the middle, 1 the
        # bottom end. That decides how steeply it comes off.
        where = (ball.pos.y - paddle.pos.y) / (PADDLE_H / 2)
        where = clamp(where, -1, 1)
        ball.dy = where * speed * BALL_ANGLE
        play("hit")

    # ------------------------------------------------------------ scoring

    def point_to(who):
        global player_score, cpu_score, playing

        # Park the ball in the middle, standing still, until the next serve.
        ball.pos = vec2(width() / 2, height() / 2)
        ball.dx = 0
        ball.dy = 0
        message.text = ""

        if who == "player":
            player_score = player_score + 1
            player_label.text = str(player_score)
        else:
            cpu_score = cpu_score + 1
            cpu_label.text = str(cpu_score)

        if player_score == WIN_SCORE or cpu_score == WIN_SCORE:
            playing = False
            ball.destroy()
            play("win")
            if player_score == WIN_SCORE:
                message.text = "You win! Press SPACE or click to play again"
            else:
                message.text = ("The computer wins. Press SPACE or click "
                                "to play again")
            return

        play("point")
        # The next serve goes towards whoever just lost the point.
        if who == "player":
            wait(1, serve_right)
        else:
            wait(1, serve_left)

    # Once the game is over, space or a click starts a new one.
    def again():
        if not playing:
            go("game")

    onKeyPress("space", again)
    onClick(again)


# ------------------------------------------------------- 4. start the game

go("game")

Make it yours

Open the whole game in the playground → It goes straight into the editor, ready to change and run.

  • Make the computer easier or harder. CPU_SPEED is how fast it can move. Make it smaller and you'll beat it more often; make it bigger and you won't.
  • Change the shape of the game. A bigger PADDLE_H is easier to hit with. A bigger BALL_GAIN makes rallies get fast sooner. A smaller WIN_SCORE makes a quicker game.
  • Two players. Take out the computer's @onUpdate. Change your paddle's keys to "w" and "s", so two people at one keyboard aren't in each other's way, and give the right-hand paddle two handlers of its own with @onKeyDown("up") and @onKeyDown("down"), moving cpu instead of player.
  • A different ball. Make it a picture: load one with loadSprite("apple", "images/apple.png") at the top and use sprite("apple") instead of rect(BALL_SIZE, BALL_SIZE). Click Sprites in the playground to see what there is. If the picture is much bigger than 16 pixels, add scale() to shrink it, or change BALL_SIZE to match.