Lesson 7 — Decorators

By the end of this you can: read the @ lines that every kaypy game is built out of, write your own, tell the two shapes apart, and recognise the three ways they go wrong without saying anything.

This is the last piece of Python you need before the games in the tutorials read as ordinary code rather than magic.

The line you keep writing

Lesson 6 ended with this pattern: define a function, then hand it to the engine by name.

from kaypy import *

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


def announce():
    print("Tick")


loop(1.0, announce)

It works, and the same shape covers everything a game reacts to:

onKeyPress("space", jump)
onUpdate(move_everything)
onCollide("bullet", "enemy", explode)

There is one thing wrong with it, and it is not the typing. It is that the function and the fact that it is a handler are in two different places. You read def announce():, get to the bottom of it, and only then find out what calls it. In a game with fifteen handlers, the list of hand-overs at the end is a table of contents nobody keeps in order.

The same thing, written above it

An @ line moves the hand-over to the top:

from kaypy import *

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


@loop(1.0)
def announce():
    print("Tick")

That is a decorator, and it means exactly what the previous version meant. Python reads it in this order:

  1. Define the function announce, the normal way.
  2. Hand it to loop(1.0).
  3. Leave the name announce pointing at your function.

So the whole of the @ syntax is: define this, then give it to that. The work still happens when the engine decides — a second from now, when a key goes down, when two things touch — not when Python reads the line.

Step 3 is worth saying out loud, because people assume the function has been swallowed. It has not:

from kaypy import *

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


@onKeyPress("space")
def jump():
    print("Jumped!")


print("jump is still called", jump.__name__)
jump()
jump is still called jump
Jumped!

It is registered with the engine and it is still an ordinary function you can call yourself. That matters when you want the same thing to happen for two reasons — a key press and a collision — without writing it twice.

Two shapes

Some decorators have brackets and some do not, and the difference is not style:

from kaypy import *

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


@onUpdate
def every_frame():
    pass


@onKeyPress("space")
def on_space():
    pass


@loop(0.5)
def twice_a_second():
    pass


print("three handlers registered")
three handlers registered

The rule: brackets when it needs to know something else.

  • @onUpdate needs nothing. Every frame is every frame, so there is nothing to tell it and no brackets.
  • @onKeyPress("space") needs the key. @loop(0.5) needs the interval. @onCollide("player", "spike") needs both tags.

Read @onKeyPress("space") as two steps: onKeyPress("space") runs first and produces something that is waiting for a function, and the @ hands your function to it. That is why the brackets are there — they are an ordinary call, made before your function exists.

@onUpdate also takes a tag, and then it has brackets like the rest:

from kaypy import *

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

add([rect(30, 30), pos(100, 100), color(200, 60, 60), "enemy"])
add([rect(30, 30), pos(200, 100), color(200, 60, 60), "enemy"])


@onUpdate("enemy")
def drift(enemy):
    enemy.pos.x = enemy.pos.x + 60 * dt()

@onUpdate("enemy") runs once per frame for each tagged object, and hands you the object it is talking about. Two enemies means drift is called twice a frame, with a different enemy each time. The "enemy" in the component list is what puts an object in that group — a plain string among the components is a tag.

Forgetting the brackets

This is the mistake, and it is a bad one because nothing complains:

from kaypy import *

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


@onKeyPress
def jump():
    print("Jumped!")


print("jump is now called", jump.__name__)
jump is now called decorator

No error. The program runs. Space does nothing, forever.

Here is what happened. onKeyPress expects the key first, so it took your function as the key — and then, having been given no function, it did what it does when it is used with brackets: it handed back the thing that waits for one. That thing is now sitting under the name jump, and your function is gone. Nothing was ever registered, so nothing was ever going to fire.

print(jump.__name__) is the quickest way to catch it. If it says anything other than the name you wrote, the decorator above it is the wrong shape.

When a handler does nothing at all, check this line first. The instinct is to suspect the collision, the key name, the sprite — all of which are harder to check and none of which will be the problem when the @ line is missing its brackets.

What the engine hands you

Each kind of handler is called with different things, and the tutorials' examples are the quickest reference:

  • @onUpdate, @onKeyPress("x"), @loop(1) — nothing.
  • @onUpdate("enemy") — the tagged object.
  • @onCollide("bullet", "enemy") — both objects, in the order the tags were named.
  • @player.onCollide("coin") — the other object, since you already have the player.

kaypy is relaxed about this in one direction: your function may take fewer parameters than it is offered, and the extras are dropped. Both of these are fine, and you write the second when you do not care which coin it was:

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 36])
loadSprite("coin", "images/coin.png")

player = add([rect(30, 30), pos(100, 100), area()])
add([sprite("coin"), pos(100, 100), area(), "coin"])

coins = 0


@player.onCollide("coin")
def picked_up(coin):
    global coins
    coins = coins + 1
    destroy(coin)


@player.onCollide("coin")
def play_a_noise():
    print("ding")
ding

Taking more than it is offered is not forgiven — that is a TypeError naming the parameter nothing was supplied for, which is at least loud.

And note what the first one needed: global coins, from lesson 6. Every decorated handler is a function, so every decorated handler that changes a score needs it.

Decorating something that belongs to an object

@player.onCollide("coin") is worth a second look, because it is a decorator whose name has a dot in it:

@player.onCollide("coin")
def picked_up(coin):
    ...

Nothing new is going on. The @ line is still just an expression that produces something waiting for a function — this time it comes from an object you made rather than from the engine as a whole. The difference in behaviour is scope: onCollide("player", "coin") watches every player-tagged object, while player.onCollide("coin") watches that one.

Use the object form when you are holding the object, which is most of the time. Use the tag form for pairs that come and go while the game runs — bullets and enemies, where neither one is a variable you kept.

Two handlers, one name

One more thing that behaves unlike you would guess:

from kaypy import *

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

log = []


@loop(0.01)
def tick():
    log.append("first")


@loop(0.01)
def tick():
    log.append("second")


@onUpdate
def check():
    if len(log) >= 2:
        print(sorted(set(log)))
        quit()
['first', 'second']

Both of them run. Re-using the name did not replace the first handler, because the engine was handed the function itself and does not care what it was called — the name is only how you refer to it. So the second def rebinds the name and leaves the first function registered and running, with nothing left to call it by.

That is a nuisance when it is an accident, which is usually: two @onUpdate handlers both called update, and a bug that behaves like the old code never went away. Give every handler a name that says what it does and the problem disappears.

A game is a stack of these

Put together, this is what a whole small game looks like — and by now there is nothing in it you have not met:

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 36])
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")

score = 0
label = add([text("Score: 0", size=28), pos(20, 20)])
player = add([sprite("bean"), pos(400, 300), area()])


def add_point():
    global score
    score = score + 1
    label.text = "Score: " + str(score)


@onKeyDown("right")
def go_right():
    player.pos.x = player.pos.x + 240 * dt()


@onKeyDown("left")
def go_left():
    player.pos.x = player.pos.x - 240 * dt()


@loop(1.5)
def drop_a_coin():
    add([sprite("coin"), pos(rand(40, 760), rand(40, 560)), area(), "coin"])


@player.onCollide("coin")
def collect(coin):
    destroy(coin)
    add_point()

Four handlers and one ordinary function. The @ lines say when; the functions under them say what; add_point is shared by anything that ever needs to score, and is called by name because nothing in the engine calls it.

That is the whole pattern. Every game in the games section is this, with more of it.

Watch for

@ means "define this, then hand it over". The function does not run when Python reads it.

Brackets when the decorator needs to know something. @onUpdate bare, @onKeyPress("space") with.

A missing pair of brackets fails silently. print(your_handler.__name__) tells you within seconds.

Handlers are functions, so changing a score in one needs global.

Two handlers with the same name both run. Name them for what they do.

Your function may take fewer parameters than it is offered, never more.

Classwork 1 — Rewrite It With @

This program is written entirely in the hand-over style. Run it first, so you know what it does:

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 36])
loadSprite("bean", "images/bean.png")

player = add([sprite("bean"), pos(400, 300), area()])


def go_up():
    player.pos.y = player.pos.y - 200 * dt()


def go_down():
    player.pos.y = player.pos.y + 200 * dt()


def spin():
    print("spin")


onKeyDown("up", go_up)
onKeyDown("down", go_down)
loop(2.0, spin)
  1. Rewrite all three as decorators. Delete the three hand-over lines at the bottom. Run it — it should behave identically.
  2. Add left and right the same way.
  3. Add an @onKeyPress("space") that prints the player's position. Notice that onKeyDown fires every frame the key is held and onKeyPress fires once — hold both down and watch the difference in the output pane.
  4. Deliberately break one: take the brackets off @onKeyPress("space") and run it again. Then print its __name__ and confirm what it says.

Stretch: make one function handle both up and down by giving it a parameter, then register it twice — once by hand for each key, since a decorator cannot pass an argument of your choosing. Which reads better?

Classwork 2 — The Collector

Build the small game from the section above, one handler at a time. Start here:

from kaypy import *

kaypy(width=800, height=600, background=[20, 24, 36])
loadSprite("bean", "images/bean.png")
loadSprite("coin", "images/coin.png")

score = 0
label = add([text("Score: 0", size=28), pos(20, 20)])
player = add([sprite("bean"), pos(400, 300), area()])
  1. Add left and right movement with two @onKeyDown handlers.
  2. Add an add_point() function — a plain one, no decorator — that raises the score and updates the label. It needs global.
  3. Drop a coin every second and a half with @loop(1.5), at pos(rand(40, 760), rand(40, 560)), tagged "coin".
  4. Collect them with @player.onCollide("coin"): destroy the coin and call add_point().
  5. Add a timer. A seconds_left variable, a second label, and an @loop(1.0) that counts it down — and when it reaches 0, stops dropping coins. (Making the drop stop is the interesting bit: a flag the drop handler checks with an if is the simplest way.)

Stretch: make coins worth more when there are fewer of them. get("coin") hands back a list of every object with that tag, so len(get("coin")) is how many are on screen — lesson 5, doing real work.

Check yourself

Run this and press space. What happens, and why?

from kaypy import *

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

score = 0


@onKeyPress("space")
def add_point():
    score = score + 1
    print("Score:", score)
It crashes — and the traceback appears a long way from the mistake

Pressing space raises UnboundLocalError: local variable 'score' referenced before assignment, because score = score + 1 assigns to score, which makes it local for the whole function, which means the score on the right-hand side is the local one — and nothing has ever been put in it. global score at the top of the function is the fix.

The part worth noticing is when it happens. The program starts fine. The window opens, the handler registers, everything looks right — and the error arrives whenever somebody first presses space, which might be a minute later or in front of a class. A decorated function is code you have written and not yet run.

That is the real cost of the @ style, and the reason a game's handlers are worth testing one at a time as you add them rather than all at the end.



← Lesson 6 — Functions · All nine lessons · Lesson 8 — Dictionaries →