Lesson 8 — Dictionaries

By the end of this you can: hold a pile of values under names instead of numbers, look one up, cope with a name that is not there, go through the lot, and read the two dictionary shapes that every kaypy game is built on.

The problem with slot numbers

A list from lesson 5 can hold a whole monster:

goblin = ["Goblin", 30, 3, 90]

print(goblin[1])
30

What is 30? You have to go back and count. Second slot, so… health, probably. And goblin[3] is 90, which could be speed, or gold, or how far it can see.

Now imagine that list is written in one file and read in another, and that somebody adds an armour value in the middle. Every goblin[2] in the program now quietly means something else. Nothing errors. The goblin just starts behaving oddly.

Lists are right when the things in them are the same kind of thing and the order matters — every enemy on screen, every score this week. They are wrong when the slots mean different things, and that is what a dictionary is for.

A name for every value

Same monster, labelled:

goblin = {"name": "Goblin", "health": 30, "attack": 3, "speed": 90}

print(goblin["health"])
print(goblin["name"], "hits for", goblin["attack"])
30
Goblin hits for 3

Curly brackets instead of square ones, and every value has a key in front of it with a colon between. Written out over several lines it reads like a character sheet, which is exactly what it is:

goblin = {
    "name": "Goblin",
    "health": 30,
    "attack": 3,
    "speed": 90,
}

print(goblin)
{'name': 'Goblin', 'health': 30, 'attack': 3, 'speed': 90}

The comma after the last one is allowed and is worth getting into the habit of: adding a line later then touches one line instead of two.

Looking a value up uses square brackets, same as a list — goblin["health"]. The difference is what goes inside them. A list takes a position; a dictionary takes a key you chose.

Changing and adding

Assignment works the way it does everywhere else:

goblin = {"name": "Goblin", "health": 30}

goblin["health"] = goblin["health"] - 12
print(goblin["health"])

goblin["gold"] = 7
print(goblin)
18
{'name': 'Goblin', 'health': 18, 'gold': 7}

The first change is the one you would expect: goblin["health"] behaves like any other variable, so goblin["health"] - 12 is worked out first and the answer goes back in.

The third line is the surprising one. goblin["gold"] = 7 uses a key that did not exist, and rather than complaining, Python adds it. There is no append for dictionaries, because there is nothing to append to — you just assign to the key you want and it appears.

Which cuts both ways. A typo on the left-hand side makes a new entry instead of changing the one you meant:

goblin = {"health": 30}

goblin["helth"] = 10

print(goblin)
print(goblin["health"])
{'health': 30, 'helth': 10}
30

No error, two keys, and a health value that never moves however many times you hurt the thing.

When the key is not there

Reading a missing key is not so quiet:

goblin = {"name": "Goblin", "health": 30}

print(goblin["armour"])
KeyError: 'armour'

KeyError is one of the friendlier errors — it tells you the exact key it could not find, and the fix is nearly always a spelling mistake or a key you have not set yet.

Two ways to deal with it. Ask first, with in:

goblin = {"name": "Goblin", "health": 30}

if "armour" in goblin:
    print("Armoured")
else:
    print("No armour")

print("health" in goblin)
No armour
True

Or ask for it with a fallback, using .get():

goblin = {"name": "Goblin", "health": 30}

print(goblin.get("armour", 0))
print(goblin.get("health", 0))
print(goblin.get("armour"))
0
30
None

goblin.get("armour", 0) means give me armour, or 0 if there is no armour. With no second value it hands back None — the same None a function with no return gives you, from lesson 6.

.get() is the right tool when a missing key is normal — some monsters have armour and some do not. Square brackets are right when a missing key is a bug, and you would rather be told.

Going through one

A for loop over a dictionary gives you the keys:

goblin = {"name": "Goblin", "health": 30, "attack": 3}

for key in goblin:
    print(key)
name
health
attack

They come out in the order they were put in, which is worth knowing: a dictionary is not sorted, it just remembers.

Most of the time you want both halves, and .items() hands them over in pairs:

goblin = {"name": "Goblin", "health": 30, "attack": 3}

for key, value in goblin.items():
    print(key, "=", value)
name = Goblin
health = 30
attack = 3

Two loop variables, because each thing the loop hands over is a pair. There is .keys() and .values() as well, for when you only want one side:

prices = {"sword": 40, "shield": 25, "potion": 5}

print(len(prices))
print(sum(prices.values()))

for item in prices.keys():
    print(item)
3
70
sword
shield
potion

len() and sum() are the same ones from lesson 5. A dictionary is a pile of things you can count and add up, like a list — it just has labels.

A list of dictionaries

Neither one on its own is the shape real game data takes. Together they are:

monsters = [
    {"name": "Goblin", "health": 30, "attack": 3},
    {"name": "Skeleton", "health": 40, "attack": 4},
    {"name": "Chort", "health": 60, "attack": 6},
]

for monster in monsters:
    print(monster["name"], "-", monster["health"], "hp")

print("Total health:", sum(m["health"] for m in monsters))
Goblin - 30 hp
Skeleton - 40 hp
Chort - 60 hp
Total health: 130

A list of them, because the monsters are all the same kind of thing and there may be any number. A dictionary for each one, because a monster's name and its health are not the same kind of thing at all.

That rule — list for many of the same, dictionary for one thing's parts — will see you through nearly every decision of this kind.

Hurting one works exactly as you would hope, and this is the part that surprises people the first time:

monsters = [
    {"name": "Goblin", "health": 30},
    {"name": "Skeleton", "health": 40},
]


def hurt(monster, amount):
    monster["health"] = monster["health"] - amount


hurt(monsters[0], 12)
hurt(monsters[0], 12)

print(monsters[0])
print(monsters[1])
{'name': 'Goblin', 'health': 6}
{'name': 'Skeleton', 'health': 40}

No global, and no handing anything back. The function changed something inside the dictionary, and the dictionary in the list is the same dictionary the function was given. This is the same rule as scores.append(30) in lesson 6: changing what is inside something needs nothing special, and only rebinding the name needs global.

Your own values on a game object

Here is the first of the two shapes kaypy uses. Put a dictionary among the components and its keys become things the object remembers:

from kaypy import *

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

bean = add([sprite("bean"), pos(100, 300), area(), {"hits": 0, "way": 1}])

bean.hits = bean.hits + 1
bean.hits = bean.hits + 1

print("hits:", bean.hits)
print("way:", bean.way)
hits: 2
way: 1

Note the dot rather than the square brackets once it is on the object: bean.hits, not bean["hits"]. The dictionary is how you set them up — it puts a thing's own state in the same list as the rest of what it is, instead of three assignments underneath.

kaypy will refuse a key that a component already owns. {"pos": vec2(0, 0)} next to pos(100, 300) is an error rather than a quiet overwrite, which matters: silently winning that argument would leave you with half a game object that breaks somewhere else entirely.

A table of what to build

The second shape, and the one that makes a level a picture you can edit:

from kaypy import *

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

LAYOUT = [
    "########",
    "#o....o#",
    "#..gg..#",
    "########",
]

TILES = {
    "#": lambda: [rect(48, 48), color(90, 90, 110), area(), "wall"],
    "o": lambda: [circle(12), color(240, 200, 60), area(), "coin"],
    "g": lambda: [rect(32, 32), color(200, 60, 60), area(), "goblin"],
}

addLevel(LAYOUT, {"tileWidth": 48, "tileHeight": 48, "tiles": TILES})

print("walls:", len(get("wall")))
print("coins:", len(get("coin")))
print("goblins:", len(get("goblin")))
walls: 20
coins: 2
goblins: 2

Two dictionaries are doing two different jobs there, and it is worth naming them separately.

TILES is a lookup table: addLevel walks the picture one character at a time and asks the dictionary what a # is. Every key is one character and every value is a function that builds one tile. (lambda: is a function written on one line — you met the idea in lesson 6, this is just a shorter way to write one that fits in a list.)

The second dictionary — {"tileWidth": 48, ...} — is a settings sheet: named values handed to a function so the call does not become eight arguments in an order nobody can remember.

And because the level is a picture, moving a coin means moving an o. Change one character in LAYOUT, run it again, and the level is different. That is the whole reason Dungeon Dash is built this way, and the tutorial walks through it.

Watch for

Curly brackets to make one, square brackets to look one up.

Assigning to a key that does not exist adds it. A typo on the left makes a new entry; a typo on the right is a KeyError.

.get(key, fallback) when missing is normal, square brackets when missing is a bug.

A plain for gives you the keys. .items() gives you both, in pairs.

List for many of the same thing, dictionary for one thing's parts.

Changing what is inside a dictionary needs no global — only replacing the whole thing does.

Every one of those is in Goblin Hit, a game built step by step around two dictionaries and a tally — and three of its "break it" boxes are a KeyError.

Classwork 1 — The Inventory

Start here and press Run:

prices = {"sword": 40, "shield": 25, "potion": 5}

print(prices["sword"])
40

Then, running after each step:

  1. Add a bow at 30 by assigning to a key that is not there yet. Print the whole dictionary and check it arrived.
  2. Put the sword on sale: set its price to 30. Print the dictionary again — the entry changed rather than a second one appearing, because the key already existed.
  3. Print every item and its price on its own line, using .items().
  4. Print the total cost of buying one of everything, with sum().
  5. Ask the user what they want to buy with input() from lesson 2, and print the price. Use .get() so that a typo says We do not sell that rather than crashing.

Stretch: add a second dictionary, stock, with the same keys and a count for each. Then write buy(item) that takes one off the stock and returns the price — or returns 0 and prints a message when there are none left. Two dictionaries sharing a set of keys is an extremely common shape.

Classwork 2 — Draw Your Own Level

Take the level above and make it yours.

from kaypy import *

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

LAYOUT = [
    "########",
    "#o....o#",
    "#......#",
    "########",
]

TILES = {
    "#": lambda: [rect(48, 48), color(90, 90, 110), area(), "wall"],
    "o": lambda: [circle(12), color(240, 200, 60), area(), "coin"],
}

addLevel(LAYOUT, {"tileWidth": 48, "tileHeight": 48, "tiles": TILES})
  1. Move the coins by moving the o characters. Add four more.
  2. Make the level wider and taller. Every row must be the same length — count the characters if a wall comes out in the wrong place.
  3. Add a third entry to TILES: a ^ that builds a red spike, and put some in.
  4. Add a player with loadSprite and a p tile, then move it with the @onKeyDown handlers from lesson 7.
  5. Collect the coins: @onCollide("player", "coin"), destroy the coin, and count them in a variable with global.

Stretch: put two layouts in a list and a level_number variable, so that LAYOUTS[level_number] picks which one to build. A list of pictures, each built through the same table — that is a whole game's worth of levels in about four lines.

Check yourself

What does this print?

a = {"score": 0}
b = a

b["score"] = 50

print(a["score"])
50
Why did changing b change a?

Compare it with the one at the end of lesson 1:

a = 5
b = a
a = 12

print(a, b)
12 5

There, b kept its own 5. Here, b and a move together. Both are correct, and the rule behind them is the same one: b = a copies what is in a, and what is in a is a way of getting to the dictionary, not the dictionary itself. So both names now reach the same one, and changing it through either name changes the thing both are looking at.

The number example never showed this because a = 12 is a rebinding — it points a at a different value and leaves b where it was. Do the same thing here and the two come apart again: b = {"score": 99} gives b a brand new dictionary and a keeps the old one.

It is the same distinction as global in lesson 6, wearing different clothes. Changing what is inside is visible everywhere the thing is reachable from. Replacing the whole thing only affects the one name you replaced it on.

Worth knowing because it is usually what you want: passing a monster's dictionary to hurt() and having the damage stick is this behaviour doing its job. It only bites when you meant to take a copy — and for that there is b = dict(a).



← Lesson 7 — Decorators · All nine lessons · Lesson 9 — Files →