Building Paddle Ball

Play it first → A ball bounces around the top of the window, and you keep it up with a paddle along the bottom. Every hit is a point and makes the ball a little faster. Miss it and it's over. It is Pong with one player.

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 paddle and the ball are rectangles.

Build it after Bomb Dodge, Goblin Hit and Log Hacker, and before Spiral Storm and Pong. It is put together the way Bomb Dodge is — one score, one life, global, a scene to play again in — and it adds the one idea Pong is built on.

The one big idea: the ball carries its own speed

In Bomb Dodge, a bomb falls because every frame it calls bomb.move(0, fall_speed()), and it only ever goes one way: down. A ball that bounces has to go every way, and change its mind.

So the ball keeps its speed on itself, in two numbers. ball.dx is how fast it is going across: plus is right, minus is left. ball.dy is how fast it is going down: plus is down, minus is up. Every frame, the ball moves by those two numbers.

Then bouncing is easy. When the ball hits the left or right wall, ball.dx = -ball.dx turns it round. When it hits the top, or the paddle, ball.dy = -ball.dy does. A number made negative is a bounce. That's the whole of the physics.

Step 1 — the paddle

from kaypy import *

PADDLE_W = 120         # how wide the paddle is
PADDLE_H = 16          # and how tall
PADDLE_SPEED = 520     # how fast the paddle moves, in pixels per second

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

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


@onKeyDown("left")
def move_left():
    paddle.move(-PADDLE_SPEED, 0)
    keep_on_screen()


@onKeyDown("right")
def move_right():
    paddle.move(PADDLE_SPEED, 0)
    keep_on_screen()


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


def keep_on_screen():
    paddle.pos.x = clamp(paddle.pos.x, PADDLE_W / 2, width() - PADDLE_W / 2)

Hold left or right and the paddle slides. Hold the mouse down somewhere and the paddle slides towards it. On a phone, a finger does the same thing. It stops at the edges.

  • rect(PADDLE_W, PADDLE_H) draws a plain rectangle, 120 pixels wide and 16 tall, where a sprite() would draw a picture. color(120, 220, 255) makes it light blue.
  • anchor("center") means pos() is the middle of the rectangle, so pos(width() / 2, height() - 40) puts the paddle in the middle, 40 pixels up from the bottom.
  • This is how Bean ran in Bomb Dodge, with paddle in place of bean: @onKeyDown for the arrows, @onMouseDown for the mouse, and keep_on_screen() after every move.
  • clamp(paddle.pos.x, PADDLE_W / 2, width() - PADDLE_W / 2) keeps the middle of the paddle at least half a paddle (60 pixels) from each edge, so the paddle stops with its end touching the edge.

Break it. Take keep_on_screen() out of move_left and hold left. There's no error message. The paddle slides off the edge of the window and keeps going: three seconds of holding left took it to x = −1170. Hold right and it stops at the right-hand edge as it should, because move_right still has its keep_on_screen().

Step 2 — a ball that moves itself

A ball appears near the top and sets off down and to the right. It bounces off all four sides of the window for ever. You can't do anything to it yet: the paddle is still there, but the ball goes straight through it.

from kaypy import *

PADDLE_W = 120         # how wide the paddle is
PADDLE_H = 16          # and how tall
PADDLE_SPEED = 520     # how fast the paddle moves, in pixels per second
BALL_SIZE = 16

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

loadSound("wall", "sounds/small_beep.wav")

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

# The ball carries its own speed: dx is how fast it goes across (minus is
# left) and dy is how fast it goes down (minus is up), in pixels per second.
ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
            pos(width() / 2, 100)])
ball.dx = 200
ball.dy = 300


@onKeyDown("left")
def move_left():
    paddle.move(-PADDLE_SPEED, 0)
    keep_on_screen()


@onKeyDown("right")
def move_right():
    paddle.move(PADDLE_SPEED, 0)
    keep_on_screen()


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


def keep_on_screen():
    paddle.pos.x = clamp(paddle.pos.x, PADDLE_W / 2, width() - PADDLE_W / 2)


# Every frame the ball moves by its own speed, times dt() -- the time since
# the last frame -- so it goes as fast on a slow computer.
@onUpdate
def move_ball():
    ball.pos.x = ball.pos.x + ball.dx * dt()
    ball.pos.y = ball.pos.y + ball.dy * dt()

    # The side walls: put it back inside, and turn it round.
    if ball.pos.x < BALL_SIZE / 2:
        ball.pos.x = BALL_SIZE / 2
        ball.dx = -ball.dx
        play("wall")
    if ball.pos.x > width() - BALL_SIZE / 2:
        ball.pos.x = width() - BALL_SIZE / 2
        ball.dx = -ball.dx
        play("wall")

    # The top.
    if ball.pos.y < BALL_SIZE / 2:
        ball.pos.y = BALL_SIZE / 2
        ball.dy = -ball.dy
        play("wall")

    # The bottom, for now. Step 3 takes it away.
    if ball.pos.y > height() - BALL_SIZE / 2:
        ball.pos.y = height() - BALL_SIZE / 2
        ball.dy = -ball.dy
        play("wall")
  • ball.dx = 200 and ball.dy = 300 stick two numbers of our own onto the ball. A game object will carry any number you give it like this, under any name you like. These two are the ball's speed: 200 pixels a second to the right, and 300 a second down.
  • @onUpdate runs move_ball every frame. dt() is the time since the last frame, in seconds — about 0.017 — so ball.dx * dt() is how far the ball goes across in this frame. That is the sum .move() was doing for you in Bomb Dodge.
  • BALL_SIZE / 2 is 8, half the ball. The ball's pos is its middle, so it touches the left wall when its x is 8, and the right wall when its x is width() - 8.

Each wall does two things. First it puts the ball back inside: ball.pos.x = BALL_SIZE / 2. A ball moving 200 pixels a second goes three or four pixels a frame, so by the time the if notices, it is a little way into the wall. Then it makes the speed negative, and the ball goes back the way it came.

The bottom is a wall too, but only for now, so you can watch the ball bounce. Step 3 takes it away and the paddle takes over.

Two things to break.

Take * dt() off both lines that move the ball. There's no error message, and the ball goes mad: it moves the whole of dx and dy every frame — 200 pixels across and 300 down — so it hits a wall nearly every frame and flickers from one side of the window to the other. That is sixty times too fast. dt() is what turns "pixels a second" into "pixels this frame".

Move the two lines ball.dx = 200 and ball.dy = 300 above ball = add(...). Nothing runs, and the error is NameError: name 'ball' is not defined. Python may add a guess after it, like Did you mean: 'all'? — it's only a guess, and here it's wrong. The real problem is the order: you can't put a number on the ball before there is a ball.

Step 3 — hitting it back

The bottom isn't a wall any more. Get the paddle under the ball and it bounces back up, with a beep. Miss it and the ball falls out of the window and is gone — nothing else happens yet.

from kaypy import *

PADDLE_W = 120         # how wide the paddle is
PADDLE_H = 16          # and how tall
PADDLE_SPEED = 520     # how fast the paddle moves, in pixels per second
BALL_SIZE = 16

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

loadSound("hit", "sounds/beep.wav")
loadSound("wall", "sounds/small_beep.wav")

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

# The ball carries its own speed: dx is how fast it goes across (minus is
# left) and dy is how fast it goes down (minus is up), in pixels per second.
ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
            pos(width() / 2, 100)])
ball.dx = 200
ball.dy = 300


@onKeyDown("left")
def move_left():
    paddle.move(-PADDLE_SPEED, 0)
    keep_on_screen()


@onKeyDown("right")
def move_right():
    paddle.move(PADDLE_SPEED, 0)
    keep_on_screen()


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


def keep_on_screen():
    paddle.pos.x = clamp(paddle.pos.x, PADDLE_W / 2, width() - PADDLE_W / 2)


# Every frame the ball moves by its own speed, times dt() -- the time since
# the last frame -- so it goes as fast on a slow computer.
@onUpdate
def move_ball():
    old_y = ball.pos.y
    ball.pos.x = ball.pos.x + ball.dx * dt()
    ball.pos.y = ball.pos.y + ball.dy * dt()

    # The side walls: put it back inside, and turn it round.
    if ball.pos.x < BALL_SIZE / 2:
        ball.pos.x = BALL_SIZE / 2
        ball.dx = -ball.dx
        play("wall")
    if ball.pos.x > width() - BALL_SIZE / 2:
        ball.pos.x = width() - BALL_SIZE / 2
        ball.dx = -ball.dx
        play("wall")

    # The top.
    if ball.pos.y < BALL_SIZE / 2:
        ball.pos.y = BALL_SIZE / 2
        ball.dy = -ball.dy
        play("wall")

    # The paddle. `top` is where the middle of the ball is when it sits on
    # the paddle, and `reach` is how far from the middle of the paddle it
    # can be and still touch it. Did the ball go past `top` THIS frame,
    # coming down, within reach? Checking where it was a frame ago as well
    # as where it is now means a fast ball can't jump straight through.
    top = paddle.pos.y - PADDLE_H / 2 - BALL_SIZE / 2
    across = abs(ball.pos.x - paddle.pos.x)
    reach = PADDLE_W / 2 + BALL_SIZE / 2
    if old_y <= top and ball.pos.y > top and across < reach:
        ball.pos.y = top
        hit()


def hit():
    ball.dy = -ball.dy
    play("hit")

The paddle check is the hard part of this game, so here it is a piece at a time.

  • top is where the middle of the ball is when the ball is sitting on the paddle: the middle of the paddle, up half a paddle, up half a ball. The paddle's middle is 40 pixels up from the bottom, at 560, so top is 560 − 8 − 8 = 544.
  • across is how far the ball is, sideways, from the middle of the paddle. abs() makes a number positive, so it doesn't matter which side the ball is on: 30 to the left and 30 to the right are both 30.
  • reach is how far across the ball can be and still touch the paddle: half a paddle and half a ball, 68.
  • old_y is where the ball was at the start of this frame, before it moved. old_y <= top and ball.pos.y > top means "it was above the line, and now it is below it": the ball crossed the top of the paddle this frame. With across < reach as well, it crossed it where the paddle is.

Then ball.pos.y = top puts the ball back on top of the paddle, the same way the walls put it back inside, and hit() sends it back up.

Why not just ask "is the ball touching the paddle?" Because on a slow frame, a fast ball can go from just above the paddle to just below it, and be touching it on neither frame. Asking whether it crossed the line can't be fooled that way, however fast the ball gets.

Break it. Take old_y <= top and out of the if. There's no error message, and the game seems fine — until you miss. Let the ball go past the end of the paddle, then slide the paddle under it while it is still falling: the ball jumps back up onto the paddle and bounces, as if you hadn't missed at all. Without old_y, the check is "is the ball anywhere below the line?", and a ball you've already missed is.

Step 4 — points, missing, and playing again

Now there is a score at the top. Every hit is a point. Miss, and the screen shakes, you're told your score, and space or a click starts a new game.

from kaypy import *

PADDLE_W = 120         # how wide the paddle is
PADDLE_H = 16          # and how tall
PADDLE_SPEED = 520     # how fast the paddle moves, in pixels per second
BALL_SIZE = 16

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

loadSound("hit", "sounds/beep.wav")
loadSound("wall", "sounds/small_beep.wav")
loadSound("miss", "sounds/thump.wav")

# 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".
score = 0
alive = True


@scene("game")
def game():
    global score, alive

    # A new game, so everything starts again.
    score = 0
    alive = True

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

    # The ball carries its own speed: dx is how fast it goes across (minus
    # is left) and dy is how fast it goes down (minus is up).
    ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
                pos(width() / 2, 100)])
    ball.dx = 200
    ball.dy = 300

    score_label = add([text("0", size=56), pos(width() / 2, 60),
                       anchor("center")])
    message = add([text("", size=26, width=560),
                   pos(width() / 2, height() / 2), anchor("center"), z(10)])

    @onKeyDown("left")
    def move_left():
        paddle.move(-PADDLE_SPEED, 0)
        keep_on_screen()

    @onKeyDown("right")
    def move_right():
        paddle.move(PADDLE_SPEED, 0)
        keep_on_screen()

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

    def keep_on_screen():
        paddle.pos.x = clamp(paddle.pos.x, PADDLE_W / 2,
                             width() - PADDLE_W / 2)

    @onUpdate
    def move_ball():
        if not alive:
            return

        old_y = ball.pos.y
        ball.pos.x = ball.pos.x + ball.dx * dt()
        ball.pos.y = ball.pos.y + ball.dy * dt()

        # The side walls: put it back inside, and turn it round.
        if ball.pos.x < BALL_SIZE / 2:
            ball.pos.x = BALL_SIZE / 2
            ball.dx = -ball.dx
            play("wall")
        if ball.pos.x > width() - BALL_SIZE / 2:
            ball.pos.x = width() - BALL_SIZE / 2
            ball.dx = -ball.dx
            play("wall")

        # The top.
        if ball.pos.y < BALL_SIZE / 2:
            ball.pos.y = BALL_SIZE / 2
            ball.dy = -ball.dy
            play("wall")

        # The paddle.
        top = paddle.pos.y - PADDLE_H / 2 - BALL_SIZE / 2
        across = abs(ball.pos.x - paddle.pos.x)
        reach = PADDLE_W / 2 + BALL_SIZE / 2
        if old_y <= top and ball.pos.y > top and across < reach:
            ball.pos.y = top
            hit()

        # Gone past the bottom?
        if ball.pos.y > height() + BALL_SIZE:
            missed()

    def hit():
        global score

        ball.dy = -ball.dy
        play("hit")

        score = score + 1
        score_label.text = str(score)

    def missed():
        global alive

        alive = False
        play("miss")
        shake(8)
        ball.destroy()
        message.text = (str(score)
                        + " points -- press SPACE or click to play again")

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

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


go("game")

This step is Bomb Dodge's step 4 all over again, so it is put together the same way.

  • Everything is inside @scene("game"). go("game") at the bottom runs it, and running it again throws away everything — paddle, ball, words, handlers — and starts from the top. That's how playing again works.
  • score and alive live outside the scene, and every function that changes one says global first. hit changes score; missed changes alive.
  • if ball.pos.y > height() + BALL_SIZE: waits until the ball is right out of the window, so you see it go.
  • missed() sets alive to False, destroys the ball, and puts up the message. again() only starts a new game when alive is False, so a click to move the paddle doesn't throw your game away.
  • if not alive: return at the top of move_ball stops the ball code once the game is over. See the second break below for why.

Two things to break.

Take global score out of hit. The game starts as normal, and the first time the ball touches the paddle it stops with UnboundLocalError: cannot access local variable 'score' where it is not associated with a value. Without global, a function that changes score gets a new score of its own, which has no value yet.

Take out if not alive: and the return under it. There's no error, but when you miss, the miss sound plays over and over — 33 times in the first second — and the screen won't stop shaking. A destroyed ball still has a pos, so move_ball keeps moving it, finds it below the window every frame, and calls missed() every frame.

Step 5 — faster every hit

Every hit sends the ball back up a little faster than it came down, so the game gets harder the longer you last. It stops getting faster at 800 pixels a second.

from kaypy import *

PADDLE_W = 120         # how wide the paddle is
PADDLE_H = 16          # and how tall
PADDLE_SPEED = 520     # how fast the paddle moves, in pixels per second
BALL_SIZE = 16
BALL_GAIN = 25         # how much faster every hit makes the ball
BALL_FASTEST = 800     # ...and the fastest it ever goes

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

loadSound("hit", "sounds/beep.wav")
loadSound("wall", "sounds/small_beep.wav")
loadSound("miss", "sounds/thump.wav")

# 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".
score = 0
alive = True


@scene("game")
def game():
    global score, alive

    # A new game, so everything starts again.
    score = 0
    alive = True

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

    # The ball carries its own speed: dx is how fast it goes across (minus
    # is left) and dy is how fast it goes down (minus is up).
    ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
                pos(width() / 2, 100)])
    ball.dx = 200
    ball.dy = 300

    score_label = add([text("0", size=56), pos(width() / 2, 60),
                       anchor("center")])
    message = add([text("", size=26, width=560),
                   pos(width() / 2, height() / 2), anchor("center"), z(10)])

    @onKeyDown("left")
    def move_left():
        paddle.move(-PADDLE_SPEED, 0)
        keep_on_screen()

    @onKeyDown("right")
    def move_right():
        paddle.move(PADDLE_SPEED, 0)
        keep_on_screen()

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

    def keep_on_screen():
        paddle.pos.x = clamp(paddle.pos.x, PADDLE_W / 2,
                             width() - PADDLE_W / 2)

    @onUpdate
    def move_ball():
        if not alive:
            return

        old_y = ball.pos.y
        ball.pos.x = ball.pos.x + ball.dx * dt()
        ball.pos.y = ball.pos.y + ball.dy * dt()

        # The side walls: put it back inside, and turn it round.
        if ball.pos.x < BALL_SIZE / 2:
            ball.pos.x = BALL_SIZE / 2
            ball.dx = -ball.dx
            play("wall")
        if ball.pos.x > width() - BALL_SIZE / 2:
            ball.pos.x = width() - BALL_SIZE / 2
            ball.dx = -ball.dx
            play("wall")

        # The top.
        if ball.pos.y < BALL_SIZE / 2:
            ball.pos.y = BALL_SIZE / 2
            ball.dy = -ball.dy
            play("wall")

        # The paddle.
        top = paddle.pos.y - PADDLE_H / 2 - BALL_SIZE / 2
        across = abs(ball.pos.x - paddle.pos.x)
        reach = PADDLE_W / 2 + BALL_SIZE / 2
        if old_y <= top and ball.pos.y > top and across < reach:
            ball.pos.y = top
            hit()

        # Gone past the bottom?
        if ball.pos.y > height() + BALL_SIZE:
            missed()

    def hit():
        global score

        # A little faster than it came down, but never faster than
        # BALL_FASTEST. Then make it negative, which sends it back up.
        speed = ball.dy + BALL_GAIN
        if speed > BALL_FASTEST:
            speed = BALL_FASTEST
        ball.dy = -speed
        play("hit")

        score = score + 1
        score_label.text = str(score)

    def missed():
        global alive

        alive = False
        play("miss")
        shake(8)
        ball.destroy()
        message.text = (str(score)
                        + " points -- press SPACE or click to play again")

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

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


go("game")

All the change is in hit.

  • The ball is coming down when it hits the paddle, so ball.dy is positive: 300 to begin with. speed = ball.dy + BALL_GAIN makes that 325.
  • if speed > BALL_FASTEST: stops it there once it reaches 800. Twenty hits takes it from 300 to 800.
  • ball.dy = -speed makes it negative, which sends the ball back up — 25 faster than it came down. The top wall flips it again, and it comes back down at the new speed.
  • Only dy, the up-and-down speed, gets faster. If dx grew too, the ball would soon be going sideways faster than the paddle can follow. A game you can't win isn't harder, it's broken.

Break it. Leave out the minus: ball.dy = speed. There's no error message, and the ball doesn't bounce. It lands on the paddle and slides along it, still going down, so every frame it crosses top again and scores again: four points in a tenth of a second, until it slides off the end and falls.

Step 6 — the whole game

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

  • A serve. The ball starts still, and a second and a half later serve() sets it going: always down, and sideways a random amount, left or right. rand(150, 250) picks the sideways speed, and if chance(0.5): — true half the time — makes it go left instead. Every game starts differently.
  • A best score that is remembered. getData(BEST_KEY, 0) reads the best score saved last time, or 0 if there isn't one, and setData saves a new one when you beat it. BEST_KEY is "paddle_ball_best" rather than just "best", because every game on a website shares the same storage.
  • A message at the start saying how to move, which goes away at your first hit, and "1 point" rather than "1 points".

It also has all the numbers worth changing in one settings block near the top, and comments that explain why each part is there.

"""Paddle Ball -- keep the ball up. Pong, for one.

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

The ball bounces off the top and the sides. Hit it back up with your paddle
and that is a point. Every hit makes the ball a little faster. Miss it and
it is game over. Your best score is remembered.

HOW THIS FILE IS PUT TOGETHER

    1. the window and the sounds
    2. the settings
    3. the game scene: the paddle, the ball, bouncing, points, missing
    4. start the game

There are no pictures at all. The paddle and the ball are rectangles, and
the ball is a game object that carries its own speed: ball.dx is how fast
it is going across, and ball.dy is how fast it is going down. Bouncing is
just making one of them negative. After this, try Spiral Storm, and then
Pong.
"""

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("miss", "sounds/thump.wav")


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

PADDLE_W = 120         # how wide the paddle is
PADDLE_H = 16          # and how tall
PADDLE_SPEED = 520     # how fast the paddle moves, in pixels per second

BALL_SIZE = 16
BALL_START = 300       # how fast the ball comes down at the start
BALL_GAIN = 25         # ...how much faster every hit makes it
BALL_FASTEST = 800     # ...and the fastest it ever goes

# The name the best score is saved under. Every game on a website shares
# the same storage, so the game's own name goes in it.
BEST_KEY = "paddle_ball_best"


# ------------------------------------------------------- 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".

score = 0
alive = True


@scene("game")
def game():
    global score, alive

    # A new game, so everything starts again.
    score = 0
    alive = True
    best = getData(BEST_KEY, 0)

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

    # The ball starts still. serve() below sets it going.
    ball = add([rect(BALL_SIZE, BALL_SIZE), anchor("center"),
                pos(width() / 2, 100)])
    ball.dx = 0
    ball.dy = 0

    score_label = add([text("0", size=56), pos(width() / 2, 60),
                       anchor("center")])
    best_label = add([text("best " + str(best), size=18), pos(12, 12),
                      opacity(0.75)])

    # width=560 wraps a long message onto more than one line.
    message = add([text("Left and right arrows, or hold the mouse where "
                        "you want your paddle. Keep the ball up!",
                        size=26, width=560),
                   pos(width() / 2, height() / 2), anchor("center"), z(10)])

    # ------------------------------------------------------- the paddle

    @onKeyDown("left")
    def move_left():
        paddle.move(-PADDLE_SPEED, 0)
        keep_on_screen()

    @onKeyDown("right")
    def move_right():
        paddle.move(PADDLE_SPEED, 0)
        keep_on_screen()

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

    def keep_on_screen():
        paddle.pos.x = clamp(paddle.pos.x, PADDLE_W / 2,
                             width() - PADDLE_W / 2)

    # --------------------------------------------------------- serving
    #
    # Sideways a random amount, left or right, and always down.

    def serve():
        ball.dx = rand(150, 250)
        if chance(0.5):
            ball.dx = -ball.dx
        ball.dy = BALL_START

    wait(1.5, serve)

    # -------------------------------------------------------- the ball
    #
    # Every frame the ball moves by its own speed, times dt() -- the time
    # since the last frame -- so it goes as fast on a slow computer.

    @onUpdate
    def move_ball():
        if not alive:
            return

        old_y = ball.pos.y
        ball.pos.x = ball.pos.x + ball.dx * dt()
        ball.pos.y = ball.pos.y + ball.dy * dt()

        # The side walls: put it back inside, and turn it round.
        if ball.pos.x < BALL_SIZE / 2:
            ball.pos.x = BALL_SIZE / 2
            ball.dx = -ball.dx
            play("wall")
        if ball.pos.x > width() - BALL_SIZE / 2:
            ball.pos.x = width() - BALL_SIZE / 2
            ball.dx = -ball.dx
            play("wall")

        # The top.
        if ball.pos.y < BALL_SIZE / 2:
            ball.pos.y = BALL_SIZE / 2
            ball.dy = -ball.dy
            play("wall")

        # The paddle. `top` is where the middle of the ball is when it
        # sits on the paddle, and `reach` is how far from the middle of the
        # paddle it can be and still touch it. Did the ball go past `top`
        # THIS frame, coming down, within reach? Checking where it was a
        # frame ago as well as where it is now means a fast ball can't
        # jump straight through.
        top = paddle.pos.y - PADDLE_H / 2 - BALL_SIZE / 2
        across = abs(ball.pos.x - paddle.pos.x)
        reach = PADDLE_W / 2 + BALL_SIZE / 2
        if old_y <= top and ball.pos.y > top and across < reach:
            ball.pos.y = top
            hit()

        # Gone past the bottom?
        if ball.pos.y > height() + BALL_SIZE:
            missed()

    # ------------------------------------------------------- hitting it

    def hit():
        global score

        # A little faster than it came down, but never faster than
        # BALL_FASTEST. Then make it negative, which sends it back up.
        # Only the up-and-down speed grows: if the sideways speed grew too,
        # the ball would soon cross the window faster than the paddle can.
        speed = ball.dy + BALL_GAIN
        if speed > BALL_FASTEST:
            speed = BALL_FASTEST
        ball.dy = -speed
        play("hit")

        score = score + 1
        score_label.text = str(score)
        message.text = ""

    # -------------------------------------------------------- missing it

    def missed():
        global alive

        alive = False
        play("miss")
        shake(8)
        ball.destroy()

        if score > best:
            setData(BEST_KEY, score)
            best_label.text = "best " + str(score)

        if score == 1:
            message.text = "1 point -- press SPACE or click to play again"
        else:
            message.text = (str(score)
                            + " points -- press SPACE or click to play again")

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

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


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

go("game")

Break it. Write wait(1.5, serve()), with brackets after serve. There's no error message, and the ball sets off the moment the game starts, with no pause at all. The brackets run serve straight away, and hand wait what it gave back, which is nothing. wait wants the function itself, to run later — so no brackets.

Make it yours

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

  • Change the numbers. Make PADDLE_W 60 and it's a lot harder; make BALL_GAIN 60 and it gets fast in a hurry.
  • A ball that goes red. Give the ball color(255, 255, 255) in its list — white, which is how it looks already. Then in hit, after the speed is worked out, if speed == BALL_FASTEST: with ball.color = RED under it, and the ball turns red when it's going its fastest. Without the color() in its list, the ball has no colour to change, and staying white is all it does.
  • A picture for a ball. loadSprite("apple", "images/apple.png") near the top, and sprite("apple"), scale(0.4) in place of rect(BALL_SIZE, BALL_SIZE). That makes the apple about 24 pixels across, so make BALL_SIZE 24 to match, or the apple goes a little way into each wall before it bounces.
  • Now play Spiral Storm. Spiral Storm is a break from paddles: a bullet hell, where a hundred bullets fly by themselves and you dodge them all. After that, Pong is this game turned on its side, with a second paddle that the computer moves, and a ball that goes off at an angle when it hits the end of a paddle.