Building Goblin Hit

Play it first → Monsters pop up out of nine holes. Click one before it ducks back down. Every kind is worth something different, and the quick ones are worth the most. Don't click the angel. After thirty seconds you see what you hit, and your best score is remembered.

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. The pictures are the dungeon monsters from the playground's Sprites button.

It is the game for after Lesson 8 — Dictionaries, and it comes after Ghost Chase and Bomb Dodge. It uses the same pieces as Bomb Dodge: functions, global, wait() and a scene. If any of those are new, build Bomb Dodge first. After this one, try Log Hacker, then Paddle Ball.

The one big idea: the name looks everything up

Every monster has a name: "goblin", "imp", "angel". That name is the key to everything about it. It is the name its picture is loaded under, so sprite("imp") shows it. It is the key in a dictionary of points, so POINTS["imp"] is what an imp is worth. It is the key in a dictionary of times, so STAY["imp"] is how long an imp stays up. And at the end, it is the key in a dictionary that counts how many imps you hit.

Nothing in the game ever says if it is an imp, then…. It looks the name up. That is why adding a monster of your own is one new line in each dictionary, and no new code at all.

Step 1 — a goblin to click

Nine holes and one goblin. Click the goblin and it jumps to another hole.

from kaypy import *

kaypy(width=800, height=600, background=[72, 62, 78])

loadSprite("goblin", "dungeon/goblin.png", sliceX=8)

# The nine holes, in three rows of three. A hole is a dark rectangle
# with round ends: radius=20 rounds its corners.
for row in range(3):
    for column in range(3):
        x = 220 + column * 180
        y = 230 + row * 150
        add([rect(120, 40, radius=20), pos(x, y), anchor("center"),
             color(20, 14, 24), "hole"])

score = 0
score_label = add([text("0", size=48), pos(width() / 2, 40),
                   anchor("center")])

# anchor("bot") puts the goblin's feet in a hole. area() is what lets
# isHovering() ask whether the mouse is over it.
goblin = add([sprite("goblin"), pos(400, 388), anchor("bot"), scale(5),
              area()])


def jump_to_a_hole():
    hole = choose(get("hole"))
    goblin.pos = vec2(hole.pos.x, hole.pos.y + 8)


# onClick runs on every click, wherever it is. A tap on a phone counts.
@onClick
def click():
    global score

    if goblin.isHovering():
        score = score + 1
        score_label.text = str(score)
        jump_to_a_hole()
  • The holes are two loops, one inside the other. The outside loop goes round three times, once for each row. Each time round, the inside loop goes round three times, once for each column. That's nine holes. 220 + column * 180 puts the columns at 220, 400 and 580 across; 230 + row * 150 puts the rows at 230, 380 and 530 down. Each hole is tagged "hole", so get("hole") can find them all again.
  • A hole is a rectangle with round ends. rect(120, 40, radius=20) is 120 pixels wide and 40 tall, and a radius of 20 (half its height) rounds the corners all the way into a curve.
  • The goblin is scale(5). The dungeon pictures are tiny, 16 pixels across, so the goblin is drawn five times as big. sliceX=8 where it is loaded says the picture is eight goblins in a row (the frames of its animation), and to cut it into eight. The game only ever shows the first.
  • @onClick runs click on every click, wherever it is. A tap on a phone counts as a click. goblin.isHovering() asks whether the mouse is over the goblin right now. If it is, that's a point, and the goblin jumps.
  • jump_to_a_hole() picks one of the nine holes with choose() and moves the goblin's feet to it. It is picked at random, so now and then it is the hole the goblin was already in, and the goblin seems not to move at all.

Break it. Take area() out of the goblin's list. The first click, anywhere at all, stops the game with

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

area() is what gives the goblin a shape the mouse can be over. Without it there is no isHovering() to ask, and the message says so: "has no attribute" in kaypy nearly always means a component is missing from the list.

Step 2 — popping up and ducking

Now the goblins come up by themselves. Every 0.7 seconds one comes up in a hole, and 1.4 seconds later it ducks back down, so there are often two up at once. Click one before it goes.

from kaypy import *

STAY = 1.4        # how long a goblin stays up, in seconds
WAIT = 0.7        # seconds between goblins

kaypy(width=800, height=600, background=[72, 62, 78])

loadSprite("goblin", "dungeon/goblin.png", sliceX=8)

# The nine holes. The dictionary at the end, {"busy": False}, gives each
# hole a value of its own: hole.busy is True while a goblin is in it.
for row in range(3):
    for column in range(3):
        x = 220 + column * 180
        y = 230 + row * 150
        add([rect(120, 40, radius=20), pos(x, y), anchor("center"),
             color(20, 14, 24), {"busy": False}, "hole"])

score = 0
score_label = add([text("0", size=48), pos(width() / 2, 40),
                   anchor("center")])


def pop_up():
    # A hole at random. If a goblin is already in it, there is no new one
    # this time -- the wait() below still comes round again.
    hole = choose(get("hole"))
    if not hole.busy:
        hole.busy = True

        # The dictionary gives the goblin two values of its own: which
        # hole it is in, and how long it has left before it ducks.
        add([sprite("goblin"), pos(hole.pos.x, hole.pos.y + 8),
             anchor("bot"), scale(5), area(), z(5),
             {"hole": hole, "stay": STAY}, "monster"])

    wait(WAIT, pop_up)


wait(0.5, pop_up)


# Every goblin runs this, every frame: its time runs down, and when there
# is none left it goes back down its hole.
@onUpdate("monster")
def stay_up(monster):
    monster.stay = monster.stay - dt()
    if monster.stay <= 0:
        monster.hole.busy = False
        monster.destroy()


@onClick
def click():
    for monster in get("monster"):
        if monster.isHovering():
            hit(monster)
            return


def hit(monster):
    global score

    score = score + 1
    score_label.text = str(score)
    monster.hole.busy = False
    monster.destroy()

This is where the dictionaries start, in the shape kaypy uses for an object's own values. A dictionary in the list given to add() doesn't draw anything. Each of its keys becomes something the object remembers, read with a dot.

  • Each hole has {"busy": False}, so hole.busy is False to start with. pop_up sets it to True when a goblin comes up in it, and only puts a goblin in a hole that isn't busy. Two goblins never stand in the same hole.
  • Each goblin has {"hole": hole, "stay": STAY}. monster.hole is the hole it came up in, so it can set that hole back to not busy when it goes. monster.stay is how long it has left.
  • @onUpdate("monster") runs stay_up once a frame for every goblin. dt() is how long the last frame took, in seconds, so taking it away every frame makes stay run down like a clock. When it gets to zero the goblin's hole is free again, and the goblin is destroyed.
  • pop_up ends with wait(WAIT, pop_up). It books its own next turn, the way drop_bomb did in Bomb Dodge. The wait() is outside the if, so when the hole it picked is busy there is no goblin this time, but there is still a next time.
  • click goes through every goblin and hits the first one the mouse is over. The return stops there, so one click is one goblin.

The goblins are tagged "monster", not "goblin", because soon they won't all be goblins.

Break it. Take monster.hole.busy = False out of stay_up, then run it and don't click anything. There's no error message. Nine goblins come up, one in each hole, and then there are never any more. In five runs the last goblin came up somewhere between 13 and 21 seconds in.

A goblin that gets away now leaves its hole busy for ever, and once all nine are busy, pop_up never finds a free one. A goblin you hit still frees its hole, because hit has that line too, so if you click every one, the game seems fine. It is the ones you miss that jam it.

Step 3 — what each one is worth

Five kinds of monster, each worth a different number of points. The angel is worth −5, so don't click it. The points come up in yellow where the monster was, or in red when you lose them.

from kaypy import *

STAY = 1.4        # how long a monster stays up, in seconds
WAIT = 0.7        # seconds between monsters

kaypy(width=800, height=600, background=[72, 62, 78])

loadSprite("goblin", "dungeon/goblin.png", sliceX=8)
loadSprite("skeleton", "dungeon/skelet.png", sliceX=8)
loadSprite("imp", "dungeon/imp.png", sliceX=8)
loadSprite("chort", "dungeon/chort.png", sliceX=8)
loadSprite("angel", "dungeon/angel.png", sliceX=8)

# What each monster is worth. The key is the monster's name -- the same
# name its picture was loaded under -- and the value is its points.
POINTS = {
    "goblin": 1,
    "skeleton": 2,
    "imp": 3,
    "chort": 5,
    "angel": -5,
}

# Which monster comes up next is picked from this list. The more times a
# name is in it, the more often that monster comes up.
PICK_FROM = ["goblin", "goblin", "goblin", "goblin",
             "skeleton", "skeleton", "skeleton",
             "imp", "imp", "chort", "angel", "angel"]

for row in range(3):
    for column in range(3):
        x = 220 + column * 180
        y = 230 + row * 150
        add([rect(120, 40, radius=20), pos(x, y), anchor("center"),
             color(20, 14, 24), {"busy": False}, "hole"])

score = 0
score_label = add([text("0", size=48), pos(width() / 2, 40),
                   anchor("center")])


def pop_up():
    hole = choose(get("hole"))
    if not hole.busy:
        kind = choose(PICK_FROM)
        hole.busy = True

        # sprite(kind) shows the picture loaded under that name, and
        # "kind" in the dictionary lets the monster remember what it is.
        add([sprite(kind), pos(hole.pos.x, hole.pos.y + 8),
             anchor("bot"), scale(5), area(), z(5),
             {"kind": kind, "hole": hole, "stay": STAY}, "monster"])

    wait(WAIT, pop_up)


wait(0.5, pop_up)


@onUpdate("monster")
def stay_up(monster):
    monster.stay = monster.stay - dt()
    if monster.stay <= 0:
        monster.hole.busy = False
        monster.destroy()


@onClick
def click():
    for monster in get("monster"):
        if monster.isHovering():
            hit(monster)
            return


def hit(monster):
    global score

    # Look the monster's points up by its name.
    points = POINTS[monster.kind]
    score = score + points
    score_label.text = str(score)

    # How many points it was, just where the monster was: yellow for
    # points, red for losing them.
    if points > 0:
        words = "+" + str(points)
        colour = YELLOW
    else:
        words = str(points)
        colour = RED
    add([text(words, size=32), pos(monster.pos.x, monster.pos.y - 80),
         anchor("center"), color(colour), opacity(1),
         lifespan(0.5, fade=0.3), z(10)])

    monster.hole.busy = False
    monster.destroy()

POINTS is a dictionary. Curly brackets, and every value has a key in front of it with a colon between. The keys are the monsters' names, and each value is what that monster is worth. POINTS["imp"] is 3. POINTS["angel"] is −5.

Follow one monster through:

  • pop_up picks a name with kind = choose(PICK_FROM). Say it picks "imp".
  • sprite(kind) is sprite("imp"), the picture loaded under that name. That is why each monster is loaded under the same name it has in POINTS: the skeleton's picture is the file skelet.png, but it is loaded as "skeleton".
  • {"kind": kind, ...} puts the name on the monster, so monster.kind is "imp".
  • When it is clicked, POINTS[monster.kind] is POINTS["imp"], which is 3.

PICK_FROM is a list, not a dictionary, because the order and the repeats are the point of it. "goblin" is in it four times out of twelve, so a third of the monsters are goblins. The chort is in it once, so one monster in twelve is a chort.

Break it. Add a monster to two places and forget the third. Load a new one,

loadSprite("wogol", "dungeon/wogol.png", sliceX=8)

and put "wogol" on the end of PICK_FROM, but leave POINTS alone. Wogols come up, and nothing is wrong until you click one. Then the game stops with

KeyError: 'wogol'

A KeyError means a dictionary was asked for a key it doesn't have, and it tells you which key. POINTS has no "wogol", so it can't say what a wogol is worth. Add "wogol": 10, to POINTS and it works.

Step 4 — how long each one stays

Every monster stayed up for 1.4 seconds. Now each kind has its own time, and the ones worth the most are the quickest. A chort, worth 5, is gone in 0.7 seconds. Clicking the angel shakes the screen as well.

from kaypy import *

WAIT = 0.7        # seconds between monsters

kaypy(width=800, height=600, background=[72, 62, 78])

loadSprite("goblin", "dungeon/goblin.png", sliceX=8)
loadSprite("skeleton", "dungeon/skelet.png", sliceX=8)
loadSprite("imp", "dungeon/imp.png", sliceX=8)
loadSprite("chort", "dungeon/chort.png", sliceX=8)
loadSprite("angel", "dungeon/angel.png", sliceX=8)

# What each monster is worth. The key is the monster's name -- the same
# name its picture was loaded under -- and the value is its points.
POINTS = {
    "goblin": 1,
    "skeleton": 2,
    "imp": 3,
    "chort": 5,
    "angel": -5,
}

# How long each one stays up before it ducks back down, in seconds. The
# same keys as POINTS, with a different fact about each monster.
STAY = {
    "goblin": 1.4,
    "skeleton": 1.2,
    "imp": 0.9,
    "chort": 0.7,
    "angel": 1.4,
}

# Which monster comes up next is picked from this list. The more times a
# name is in it, the more often that monster comes up.
PICK_FROM = ["goblin", "goblin", "goblin", "goblin",
             "skeleton", "skeleton", "skeleton",
             "imp", "imp", "chort", "angel", "angel"]

for row in range(3):
    for column in range(3):
        x = 220 + column * 180
        y = 230 + row * 150
        add([rect(120, 40, radius=20), pos(x, y), anchor("center"),
             color(20, 14, 24), {"busy": False}, "hole"])

score = 0
score_label = add([text("0", size=48), pos(width() / 2, 40),
                   anchor("center")])


def pop_up():
    hole = choose(get("hole"))
    if not hole.busy:
        kind = choose(PICK_FROM)
        hole.busy = True

        # STAY[kind] looks up how long this kind stays up.
        add([sprite(kind), pos(hole.pos.x, hole.pos.y + 8),
             anchor("bot"), scale(5), area(), z(5),
             {"kind": kind, "hole": hole, "stay": STAY[kind]},
             "monster"])

    wait(WAIT, pop_up)


wait(0.5, pop_up)


@onUpdate("monster")
def stay_up(monster):
    monster.stay = monster.stay - dt()
    if monster.stay <= 0:
        monster.hole.busy = False
        monster.destroy()


@onClick
def click():
    for monster in get("monster"):
        if monster.isHovering():
            hit(monster)
            return


def hit(monster):
    global score

    # Look the monster's points up by its name.
    points = POINTS[monster.kind]
    score = score + points
    score_label.text = str(score)

    # How many points it was, just where the monster was: yellow for
    # points, red for losing them.
    if points > 0:
        words = "+" + str(points)
        colour = YELLOW
    else:
        shake(10)
        words = str(points)
        colour = RED
    add([text(words, size=32), pos(monster.pos.x, monster.pos.y - 80),
         anchor("center"), color(colour), opacity(1),
         lifespan(0.5, fade=0.3), z(10)])

    monster.hole.busy = False
    monster.destroy()

STAY is a second dictionary with the same keys as POINTS. Same names, a different fact about each one. The number STAY was in step 3 has become STAY[kind] where the monster is made, and that is the only line that changed to use it. Everything else that reads monster.stay carries on as before.

You could put both facts in one dictionary, with a dictionary for each monster inside it. That works, and bigger games do it. Two flat dictionaries are easier to read, and easier to change: to make imps quicker, you go to STAY and change one number.

Break it. Put quotes round kind where the monster is made, so it says STAY["kind"]. Half a second in, as the first monster comes up, the game stops with

KeyError: 'kind'

With quotes, "kind" is the word kind, four letters, and there is no monster called that. Without them, kind is the variable, which holds a name like "imp". It is one of the easiest mistakes to make with a dictionary, and the message shows you the exact key it looked for.

Step 5 — the clock, and the tally

A game now lasts thirty seconds. The time counts down in the corner. When it gets to zero, the monsters and the holes go, and you see each kind you hit: its picture, how many, and how many points they came to. Click or press space to play again.

Playing again means building everything fresh, which is what a scene is for. Everything from the holes down moves inside @scene("game"), the way it did in Bomb Dodge.

from kaypy import *

WAIT = 0.7        # seconds between monsters
ROUND = 30        # how long a game lasts, in seconds

kaypy(width=800, height=600, background=[72, 62, 78])

loadSprite("goblin", "dungeon/goblin.png", sliceX=8)
loadSprite("skeleton", "dungeon/skelet.png", sliceX=8)
loadSprite("imp", "dungeon/imp.png", sliceX=8)
loadSprite("chort", "dungeon/chort.png", sliceX=8)
loadSprite("angel", "dungeon/angel.png", sliceX=8)

POINTS = {
    "goblin": 1,
    "skeleton": 2,
    "imp": 3,
    "chort": 5,
    "angel": -5,
}

STAY = {
    "goblin": 1.4,
    "skeleton": 1.2,
    "imp": 0.9,
    "chort": 0.7,
    "angel": 1.4,
}

PICK_FROM = ["goblin", "goblin", "goblin", "goblin",
             "skeleton", "skeleton", "skeleton",
             "imp", "imp", "chort", "angel", "angel"]

score = 0
time_left = ROUND
playing = True

# How many of each monster you hit. It starts empty, and a monster's
# name goes in the first time you hit one.
hits = {}


@scene("game")
def game():
    global score, time_left, playing, hits

    # A new game, so everything starts again.
    score = 0
    time_left = ROUND
    playing = True
    hits = {}

    for row in range(3):
        for column in range(3):
            x = 220 + column * 180
            y = 230 + row * 150
            add([rect(120, 40, radius=20), pos(x, y), anchor("center"),
                 color(20, 14, 24), {"busy": False}, "hole"])

    score_label = add([text("0", size=48), pos(width() / 2, 40),
                       anchor("center"), z(10)])
    time_label = add([text(str(ROUND), size=28), pos(width() - 12, 12),
                      anchor("topright"), z(10)])
    message = add([text("", size=26), pos(width() / 2, 100),
                   anchor("center"), z(10)])

    def pop_up():
        # Time is up: no monster, and no wait() for the next one either.
        if not playing:
            return

        hole = choose(get("hole"))
        if not hole.busy:
            kind = choose(PICK_FROM)
            hole.busy = True
            add([sprite(kind), pos(hole.pos.x, hole.pos.y + 8),
                 anchor("bot"), scale(5), area(), z(5),
                 {"kind": kind, "hole": hole, "stay": STAY[kind]},
                 "monster"])

        wait(WAIT, pop_up)

    wait(0.5, pop_up)

    @onUpdate("monster")
    def stay_up(monster):
        monster.stay = monster.stay - dt()
        if monster.stay <= 0:
            monster.hole.busy = False
            monster.destroy()

    @onClick
    def click():
        if not playing:
            return
        for monster in get("monster"):
            if monster.isHovering():
                hit(monster)
                return

    def hit(monster):
        global score

        kind = monster.kind
        points = POINTS[kind]
        score = score + points
        score_label.text = str(score)

        # One more of this kind. hits.get(kind, 0) is how many there
        # were, or 0 if this is the first.
        hits[kind] = hits.get(kind, 0) + 1

        if points > 0:
            words = "+" + str(points)
            colour = YELLOW
        else:
            shake(10)
            words = str(points)
            colour = RED
        add([text(words, size=32), pos(monster.pos.x, monster.pos.y - 80),
             anchor("center"), color(colour), opacity(1),
             lifespan(0.5, fade=0.3), z(10)])

        monster.hole.busy = False
        monster.destroy()

    # Once a second, one second less.
    def tick():
        global time_left

        if not playing:
            return
        time_left = time_left - 1
        time_label.text = str(time_left)
        if time_left == 0:
            time_up()

    loop(1, tick)

    def time_up():
        global playing

        playing = False
        destroyAll("monster")
        destroyAll("hole")
        message.text = "Time! " + str(score) + " points -- click to play again"

        # One line for each kind you hit: its picture, how many, and
        # what they came to. .items() hands over each key with its value.
        y = 180
        for kind, count in hits.items():
            add([sprite(kind), pos(300, y), anchor("center"), scale(3)])
            add([text("x " + str(count) + " = " + str(count * POINTS[kind]),
                      size=28), pos(350, y), anchor("left")])
            y = y + 60

    def again():
        if not playing:
            go("game")

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


go("game")

hits is a dictionary that starts empty, {}, and fills up as you play. Each time you hit a monster,

hits[kind] = hits.get(kind, 0) + 1

adds one to that kind's count. hits.get(kind, 0) means how many of this kind there are so far, or 0 if there are none yet. The first goblin you hit, "goblin" isn't in hits yet, so .get gives 0, and hits["goblin"] is set to 1. Assigning to a key that isn't there adds it. The second goblin, .get gives 1, and it becomes 2.

hit changes hits without saying global. It changes what is inside the dictionary, which needs nothing special. The scene does say global hits, because hits = {} doesn't change the dictionary: it replaces it with a new, empty one.

The tally is a for loop over hits.items(), which hands over each key with its value: kind is the monster's name and count is how many. The name shows the picture, sprite(kind), and count * POINTS[kind] is the points they came to. Each line goes 60 pixels below the last. A dictionary remembers the order its keys went in, so the tally starts with the kind you hit first. A kind you never hit isn't in hits at all, so it has no line.

The clock is loop(1, tick), which runs tick once a second. When time_left reaches 0, tick calls time_up, which sets playing to False. That stops everything else: pop_up makes no more monsters, click hits nothing, and tick stops counting.

Two things to break.

Use square brackets instead of .get: hits[kind] = hits[kind] + 1. The first monster you hit stops the game with

KeyError: 'goblin'

or whichever kind it was. To add one, Python first has to read hits["goblin"], and the first time, there isn't one. That is what the 0 in .get(kind, 0) is for.

Take hits = {} out of the scene. There's no error message, and the first game looks right. Play a second one, and the tally at the end has both games in it. In one run the second game scored 84, and the tally under it said 24 goblins, 16 skeletons, 22 imps and 9 chorts, which comes to 167. The score starts again, because score = 0 is still there, but the dictionary carries on from the last game.

Step 6 — the whole game

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

  • Faster as you go. The time between monsters was always 0.7 seconds. Now pop_wait() works it out from how long the game has gone on: 0.7 seconds at the start, 0.015 less each second, and never less than 0.3, which it reaches about 27 seconds in. By then there are three or four monsters up at once.
  • A best score that is remembered. getData(BEST_KEY, 0) reads it, and setData saves a new one when you beat it, as in Bomb Dodge.
  • Sounds, a thump for a hit and a bang for the angel, and a message at the start saying what to do.
  • A second before you can play again. In step 5, a click a moment too late, meant for the last monster, starts a new game straight away, and the tally is gone before you have seen it. Now time_up ends with wait(1, ready_again), and only after that second does over become True, and a click start a new game.

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

"""Goblin Hit -- monsters pop out of holes. Hit them before they duck.

    click a monster (or tap it) to hit it

Nine holes, thirty seconds. Every kind of monster is worth something
different, and the quick ones are worth the most. Don't hit the angel.
When the time is up you see what you hit, and your best score is
remembered.

HOW THIS FILE IS PUT TOGETHER

    1. the window, the pictures and the sounds
    2. the monsters: what each one is worth, and how long it stays up
    3. the settings
    4. how hard the game is
    5. the game scene: the holes, popping up, hitting, time up
    6. start the game

This is the game for after dictionaries. Every monster has a name, and the
name is the key that looks up everything else about it: its picture, its
points, how long it stays up, and how many of it you hit. Add a monster
of your own and you will see -- it is one new line in each dictionary.
"""

from kaypy import *

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

kaypy(width=800, height=600, background=[72, 62, 78])

# Each monster's picture is eight little pictures in a row. sliceX=8 cuts it
# into eight, and the game only ever shows the first one.
loadSprite("goblin", "dungeon/goblin.png", sliceX=8)
loadSprite("skeleton", "dungeon/skelet.png", sliceX=8)
loadSprite("imp", "dungeon/imp.png", sliceX=8)
loadSprite("chort", "dungeon/chort.png", sliceX=8)
loadSprite("angel", "dungeon/angel.png", sliceX=8)

loadSound("hit", "sounds/thump.wav")
loadSound("ouch", "sounds/small_boom.wav")


# ------------------------------------------------------------ 2. the monsters
#
# Two dictionaries, with the same keys. A key is a monster's name -- the
# same name its picture was loaded under above -- and the value is one fact
# about it. POINTS["imp"] is how many points an imp is worth; STAY["imp"] is
# how long an imp stays up.

POINTS = {
    "goblin": 1,
    "skeleton": 2,
    "imp": 3,
    "chort": 5,
    "angel": -5,
}

STAY = {
    "goblin": 1.4,
    "skeleton": 1.2,
    "imp": 0.9,
    "chort": 0.7,
    "angel": 1.4,
}

# Which monster comes up next is picked from this list. The more times a
# name is in it, the more often that monster comes up.
PICK_FROM = ["goblin", "goblin", "goblin", "goblin",
             "skeleton", "skeleton", "skeleton",
             "imp", "imp", "chort", "angel", "angel"]


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

ROUND = 30             # how long a game lasts, in seconds

MONSTER_SIZE = 5       # how big a monster is: the pictures are 16 pixels

WAIT_START = 0.7       # seconds between monsters, to begin with
WAIT_SHORTEST = 0.3    # ...the shortest wait there ever is
WAIT_DROP = 0.015      # ...and how much shorter each second makes it

# 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 = "goblin_hit_best"


# -------------------------------------------------- 4. how hard the game is
#
# The longer the game has gone on, the less time between monsters. It
# stops somewhere, so the game ends up hard but never impossible.

time_left = ROUND


def pop_wait():
    seconds = WAIT_START - (ROUND - time_left) * WAIT_DROP
    if seconds < WAIT_SHORTEST:
        seconds = WAIT_SHORTEST
    return seconds


# ------------------------------------------------------- 5. 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
playing = True
over = False

# How many of each monster you hit. It starts empty, and a monster's
# name goes in the first time you hit one.
hits = {}


@scene("game")
def game():
    global score, time_left, playing, over, hits

    # A new game, so everything starts again.
    score = 0
    time_left = ROUND
    playing = True
    over = False
    hits = {}
    best = getData(BEST_KEY, 0)

    # The nine holes, in three rows of three. A hole is a dark rectangle
    # with round ends: radius=20 rounds its corners.
    #
    # The dictionary at the end, {"busy": False}, gives each hole a value
    # of its own: hole.busy is True while a monster is in it, so two never
    # come up in the same hole.
    for row in range(3):
        for column in range(3):
            x = 220 + column * 180
            y = 230 + row * 150
            add([rect(120, 40, radius=20), pos(x, y), anchor("center"),
                 color(20, 14, 24), {"busy": False}, "hole"])

    score_label = add([text("0", size=48), pos(width() / 2, 40),
                       anchor("center"), z(10)])
    best_label = add([text("best " + str(best), size=18), pos(12, 12),
                      opacity(0.75), z(10)])
    time_label = add([text(str(ROUND), size=28), pos(width() - 12, 12),
                      anchor("topright"), z(10)])

    message = add([text("Click the monsters! Not the angel.", size=26),
                   pos(width() / 2, 100), anchor("center"), z(10)])

    # --------------------------------------------------------- popping up

    def pop_up():
        # Time is up: no monster, and no wait() for the next one either.
        if not playing:
            return

        # A hole at random. If a monster is already in it, there is no new
        # one this time -- the wait() below still comes round again.
        hole = choose(get("hole"))
        if not hole.busy:
            kind = choose(PICK_FROM)
            hole.busy = True

            # anchor("bot") puts the monster's feet in the hole. The
            # dictionary gives it three values of its own: what kind it is,
            # which hole it is in, and how long it has left before it ducks.
            add([sprite(kind), pos(hole.pos.x, hole.pos.y + 8),
                 anchor("bot"), scale(MONSTER_SIZE), area(), z(5),
                 {"kind": kind, "hole": hole, "stay": STAY[kind]},
                 "monster"])

        wait(pop_wait(), pop_up)

    # The first monster, half a second after the game starts.
    wait(0.5, pop_up)

    # Every monster runs this, every frame: its time runs down, and when
    # there is none left it goes back down its hole.
    @onUpdate("monster")
    def stay_up(monster):
        monster.stay = monster.stay - dt()
        if monster.stay <= 0:
            monster.hole.busy = False
            monster.destroy()

    # ------------------------------------------------------------ hitting
    #
    # onClick runs on every click, wherever it is, and a tap on a phone
    # counts as a click. isHovering() asks whether the mouse is over a
    # monster, which needs area() on it.

    @onClick
    def click():
        if not playing:
            return
        for monster in get("monster"):
            if monster.isHovering():
                hit(monster)
                return

    def hit(monster):
        global score

        kind = monster.kind
        points = POINTS[kind]
        score = score + points
        score_label.text = str(score)
        message.text = ""

        # One more of this kind. hits.get(kind, 0) is how many there
        # were, or 0 if this is the first.
        hits[kind] = hits.get(kind, 0) + 1

        # How many points it was, just where the monster was.
        if points > 0:
            play("hit")
            words = "+" + str(points)
            colour = YELLOW
        else:
            play("ouch")
            shake(10)
            words = str(points)
            colour = RED
        add([text(words, size=32), pos(monster.pos.x, monster.pos.y - 80),
             anchor("center"), color(colour), opacity(1),
             lifespan(0.5, fade=0.3), z(10)])

        monster.hole.busy = False
        monster.destroy()

    # ------------------------------------------------------------- the clock

    def tick():
        global time_left

        if not playing:
            return
        time_left = time_left - 1
        time_label.text = str(time_left)
        if time_left == 0:
            time_up()

    loop(1, tick)

    # -------------------------------------------------------------- time up

    def time_up():
        global playing

        playing = False
        destroyAll("monster")
        destroyAll("hole")

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

        message.text = "Time! " + str(score) + " points"

        # One line for each kind you hit: its picture, how many, and
        # what they came to. .items() hands over each key with its value.
        y = 180
        for kind, count in hits.items():
            add([sprite(kind), pos(300, y), anchor("center"), scale(3)])
            add([text("x " + str(count) + " = " + str(count * POINTS[kind]),
                      size=28), pos(350, y), anchor("left")])
            y = y + 60

        # A second before you can start again, so a click meant for the
        # last monster does not skip past your score.
        wait(1, ready_again)

    def ready_again():
        global over

        over = True
        add([text("press SPACE or click to play again", size=24),
             pos(width() / 2, height() - 40), anchor("center")])

    def again():
        if over:
            go("game")

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


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

  • Add a monster of your own. Four lines, and no new code: load its picture, give it points in POINTS, a time in STAY, and put its name in PICK_FROM. For a wogol worth 10 that is gone in half a second:
    loadSprite("wogol", "dungeon/wogol.png", sliceX=8)
    
        "wogol": 10,      # in POINTS
        "wogol": 0.5,     # in STAY
    and "wogol" on the end of PICK_FROM. It shows up in the tally by itself. Click Sprites in the playground to see the other monsters.
  • Change what they are worth. Make the angel −20 and you will be a lot more careful. Make goblins worth 0 and they are only in the way.
  • A longer game. ROUND = 60. Then pop_wait() stops getting shorter at about 27 seconds, so the second half is all at full speed.
  • A sound for each kind. Make a third dictionary, SOUNDS = {"goblin": "hit", "angel": "ouch", ...}, load the sounds, and play(SOUNDS[kind]) in hit. Leave a monster out of it and you know what you'll see.