Dead Grid

Code your way out of the dead grid.

Ten weeks, one game, built from an empty file. A ruined city to explore, robots that hunt you, a code to crack and a hall of survivors. Every week adds one new system.

You Machines Rubble You code this map in week 3.

How each week works

1. Brief

Your teacher sends the week's mission on WhatsApp with a link to that week on this page.

2. Build

Work through the four steps in your own time. Hints and common errors sit beside every week.

3. Show and tell

Thirty minutes with your teacher. Show your game, explain your code and ask your questions.

4. The board

Your teacher marks the steps you've shown, and you move along the board.

Each step says what "done" means. Bring that to show and tell. The board measures how far you've got through the course, not how fancy your code is. A short program you can explain beats a long one you can't.

Predict before you run

Say what should happen first. When you're wrong, you've found something to learn.

Errors are clues

Read the last line of the error, then go to the line number it gives you.

Every line is yours

Don't paste in finished code from anywhere, AI included. If you can't explain a line, it isn't done.

Save each week

Work in dead_grid.py. When a week works, save a copy as week01.py, week02.py and so on.

Boot up

Start a brand-new game from an empty file, using only what you learnt in the summer.

Your mission

The power grid died three years ago. WARDEN, the machine network, sends robots through what's left of the city. This week you write the opening of Dead Grid: a title screen, a survivor with stats, a dangerous first choice and three nights of patrols.

There's no new syntax this week. Everything you need, you've already used. The difference is the empty file: nobody hands you a pattern to copy.

New this week

  • Planning before coding
  • A structure diagram
  • Starting from a blank file

You'll reuse

  • print(), input(), variables
  • if, elif, else
  • random.randint() and for loops
  • Lists, functions and while validation

Plan before you type

Big programs are built from small parts. Breaking a problem into parts is called decomposition, and a structure diagram is how you draw it: the whole game at the top, its parts underneath, and the jobs inside each part below that.

                        Dead Grid
        ┌──────────────┬──────┴───────┬──────────────┐
      Start       First choice       Nights        Ending
   ┌────┴────┐    ┌────┴────┐     ┌────┴────┐     ┌──┴──┐
 Title   Survivor  Menu  Check    Roll   Lose    Status  Message
 screen  stats           choice   dice   health

Each box near the bottom is small enough to code and test on its own. Code one box, run it, then move to the next.

Set up your folder

  1. Make a folder called dead-grid.
  2. Create dead_grid.py inside it. This is the file you'll grow all term.
  3. At the end of each week, save a copy as week01.py, week02.py and so on. If you break something next week, you can always go back.

Use the same Python and editor as the summer. If you're on a new computer, ask your teacher before installing anything.

Try it

Warm up from memory, about 10 minutes

Without looking at the summer course, write a program that:

  • asks for the survivor's name,
  • stores energy as 10,
  • takes 3 away from energy,
  • prints the name and the energy left.

Predict the output before you run it.

Name: Kai
Survivor: Kai
Energy left: 7

Done when it runs and matches your prediction.

Main mission

Write the opening scene, about 1–2 hours

Build these in order. Run the program after each one.

  1. A show_title() function that prints the game's title. Call it.
  2. Ask for the survivor's name. Store health (100), energy (10) and a kit list with two items. Print the kit with a for loop.
  3. A show_status(name, health, energy) function that prints all three values. Call it.
  4. A menu of three escape routes. A while loop must keep asking until the player types 1, 2 or 3.
  5. Each route changes at least one stat. One route adds an item to the kit with append().
  6. Three nights, using a for loop. Each night, roll random.randint(1, 6). If the roll beats a stealth value, a Hunter spots you and you lose health.
  7. Show the status again, then an ending message that depends on how much health is left.
========================================
            D E A D   G R I D
========================================
The power grid died three years ago.
WARDEN's machines patrol what is left.

Survivor, what do they call you? Kai
Welcome to the ruins, Kai
You are carrying:
- torch
- water bottle
...
Choose 1, 2 or 3: 4
No time for that. Pick a real route.
Choose 1, 2 or 3: 1
It's wet and dark, but the drones can't see you down here.

You need to cross the district before dawn.
Night 1
The patrol passes. You keep moving.
Night 2
A Hunter unit spots you! You get away, but not cleanly.
Night 3
The patrol passes. You keep moving.
...
Kai reaches the edge of the district. The real journey starts now.

Done when all seven parts work, including a wrong menu choice.

Exam corner

On paper, about 15 minutes

  1. Draw a structure diagram for your game with at least three levels.
  2. Sketch your city on squared paper: five squares by five. Name each square or mark it as rubble. You'll need it in week 3. (You can copy the map at the top of this page.)
  3. Exams use pseudocode: code-like writing that isn't any one language. Here's a validation loop for an age:
age = input("Enter your age") while age < 11 OR age > 18 print("Age must be 11 to 18") age = input("Enter your age") endwhile

Now write the pseudocode for your route menu's validation loop.

Done when all three are on paper. Bring them to show and tell.

Solo mission

Stretch yourself

Make the route choice matter later on. The route should change stealth: the drains make you harder to spot, the rooftops easier. Then add a random event to the nights: on a roll of 1, you find a battery and add it to your kit.

Done when route choice changes what happens at night.

Stay alive

Turn a story that runs once into a game that keeps listening until you escape, collapse or quit.

Your mission

The player types commands like look, move north, rest and quit, and the game answers. Every move costs energy. Start with three places, the car park, the market and the tunnel, and a bunker door to reach.

New this week

  • Game loops: while True and break
  • Functions that return a value
  • f-strings
  • .strip(), .lower(), .split()

You'll reuse

  • Functions with parameters
  • if, elif, else
  • random.randint()
  • len() and list indexes

A loop that never stops (until you say so)

In the summer, a while loop ran while a condition was true. A game loop is different: it runs forever, and a break jumps out when the game is over.

while True:
    command = input("> ")
    if command == "quit":
        break
    print("You said", command)
print("Game over")

break leaves the loop straight away. Python carries on from the first line after the loop.

Clean up what the player types

Players type Move North with capitals and stray spaces. String methods tidy it up:

text = "  Move North "
text.strip()           # "Move North"
text.strip().lower()   # "move north"
"move north".split()   # ["move", "north"]

.strip() removes spaces at both ends. .lower() makes everything lowercase. .split() cuts at spaces and gives you a list of words. The first word is the command, the second (if there is one) is what it's about.

Functions that hand something back

A function can return a value to the line that called it, the same way input() does.

def get_command():
    text = input("> ")
    return text.strip().lower()

command = get_command()   # command now holds the cleaned text

print() shows something on screen and the program forgets it. return gives a value back so the program can keep using it.

f-strings

Put an f before the quote and any variable inside {curly brackets} is filled in:

print(f"[{name}]  Health: {health}  Energy: {energy}  Turn: {turns}")

Try it

In the Shell, about 10 minutes

Type each line in the Shell. Predict each answer first.

text = "  Move North "
print(text.strip().lower())
words = text.strip().lower().split()
print(words)
print(words[0])
print(len(words))
print("take".split())
print("".split())
move north
['move', 'north']
move
2
['take']
[]

That last one matters: if the player just presses Enter, the list is empty and words[0] would crash.

Done when you can explain all six answers.

Main mission

Build the command loop, about 1–2 hours

Start a new section of dead_grid.py (keep your week 1 file as week01.py). You need:

  • get_command(), as above.
  • describe(location), which returns a description for "car park", "market", "tunnel" and "bunker", including the exits.
  • move(location, direction), which returns the new location, or the same location if you can't go that way.
  • A game loop with the commands help, look, move <direction>, rest and quit.
  • A status line, using an f-string, at the start of every turn.
  • Moving costs 1 energy. Resting gives 2 energy, but on a roll of 1 out of 6, a drone finds you and you lose 25 health.
  • The game ends when you reach the bunker, or when health or energy reaches 0.

Here's the move function and the start of the loop. Finish the rest yourself.

def move(location, direction):
    if location == "car park" and direction == "north":
        return "market"
    elif location == "market" and direction == "south":
        return "car park"
    # ...add the other routes...
    else:
        return location

while True:
    show_status(name, health, energy, turns)
    command = get_command()
    words = command.split()

    if len(words) == 0:
        print("Type a command. Try: help")
    elif words[0] == "quit":
        break
    elif words[0] == "move":
        if len(words) < 2:
            print("Move where? Try: move north")
        else:
            new_location = move(location, words[1])
            if new_location == location:
                print("You can't go that way.")
            else:
                location = new_location
                energy = energy - 1
                turns = turns + 1
                print(describe(location))
    # ...look, help, rest, and the checks for the end of the game...
Burnt-out cars. Exits: north to the market.
[Kai]  Health: 100  Energy: 6  Turn: 0
>
Type a command. Try: help
[Kai]  Health: 100  Energy: 6  Turn: 0
> move north
Collapsed stalls and smashed screens. Exits: south to the car park, east to the tunnel.
[Kai]  Health: 100  Energy: 5  Turn: 1
> rest
A drone finds you sleeping! You scramble away.
[Kai]  Health: 75  Energy: 7  Turn: 2
> move east
An old rail tunnel. Something hums in the dark. Exits: west to the market, north to the bunker.
[Kai]  Health: 75  Energy: 6  Turn: 3
> move north
A steel blast door marked with the resistance symbol.
Kai reaches the bunker door after 4 turns. To be continued...

Done when you can reach the bunker, and running out of energy ends the game.

Exam corner

Trace table, about 15 minutes

A trace table records each variable's value as a program runs, one row per change. Copy the table onto paper and fill it in without running the code.

energy = 3
turns = 0
while True:
    energy = energy - 1
    turns = turns + 1
    if energy == 0:
        break
print(turns)
energyturnsoutput
30

Then answer in one sentence: what's the difference between print and return?

Done when it's filled in. Check it by running the code afterwards.

Solo mission

Stretch yourself

Make the game easier to type. Accept n, s, e and w as well as full directions, and go as well as move. Then add a fourth place of your own with its own description and exits.

Done when go n works.

Map the ruins

Swap the hand-written routes for a real map: a grid of places you can walk around, and a Hunter that walks too.

Your mission

The city becomes a five-by-five grid. The player has a row and a column, and move north changes them. Rubble and the edges of the map block the way. A map command draws a minimap of where you've been. A Hunter moves one square at random every turn. If it lands on your square, you lose 40 health.

New this week

  • 2D lists (a list of lists)
  • city[row][col]
  • Checking the edges of a grid
  • Nested loops
  • abs() for distance

You'll reuse

  • The game loop from week 2
  • Functions that return values
  • random.randint()
  • f-strings

A list of lists

One list can hold a row of places. A list of those rows is a grid. This is a 2D list (exam papers call it a 2D array).

city = [
    ["Bunker",  "Tunnel",   "Rubble",      "Radio Mast", "Water Tower"],
    ["Rubble",  "Overpass", "Square",      "Rubble",     "Rooftops"],
    ["Archive", "Rubble",   "Clinic",      "Market",     "Warehouse"],
    ["Drains",  "Garage",   "Rubble",      "Shops",      "Rubble"],
    ["Rubble",  "Bridge",   "Bus Station", "Car Park",   "Safehouse"],
]

print(city[0][0])   # Bunker
print(city[4][3])   # Car Park

The first number picks the row (counting down from the top, starting at 0). The second picks the column (counting across from the left, starting at 0).

col 0col 1col 2col 3col 4
row 0BunkerTunnelRubbleRadio MastWater Tower
row 1RubbleOverpassSquareRubbleRooftops
row 2ArchiveRubbleClinicMarketWarehouse
row 3DrainsGarageRubbleShopsRubble
row 4RubbleBridgeBus StationCar ParkSafehouse

So moving north means row - 1, south is row + 1, east is col + 1 and west is col - 1. Use your own map from week 1 if you drew one.

Check before you step

Row 5 doesn't exist, and asking for city[5][0] crashes the game. So check the edges before looking inside the grid:

def is_open(city, row, col):
    if row < 0 or row >= len(city):
        return False
    if col < 0 or col >= len(city[0]):
        return False
    if city[row][col] == "Rubble":
        return False
    return True

len(city) is the number of rows. len(city[0]) is the number of columns in the first row.

A loop inside a loop

To visit every square, loop through the rows, and inside that, loop through the columns. This is a nested loop. The inner loop runs all the way through for every pass of the outer one.

for row in range(len(city)):
    line = ""
    for col in range(len(city[row])):
        line = line + city[row][col][0] + " "   # first letter of each place
    print(line)

How far away is the Hunter?

Count the rows between you plus the columns between you. abs() removes the minus sign, so the order doesn't matter.

distance = abs(player_row - hunter_row) + abs(player_col - hunter_col)

Try it

A tiny grid, about 15 minutes

In a new file, predict each line of output, then run it.

grid = [
    ["A", "B", "C"],
    ["D", "E", "F"],
    ["G", "H", "I"],
]
print(grid[1][2])
print(grid[2][0])
print(len(grid), len(grid[0]))
for row in range(len(grid)):
    line = ""
    for col in range(len(grid[row])):
        line = line + grid[row][col] + " "
    print(line)
F
G
3 3
A B C
D E F
G H I

Now change it so it prints the grid upside down, starting with G H I.

Done when you've printed it upside down.

Main mission

Build the map and the Hunter, about 2 hours

Replace describe() and move() from week 2 with the grid. Keep your game loop.

  1. Add city and is_open() from the lesson. The player starts at row 4, column 3.
  2. Write next_position(row, col, direction). It returns a list [row, col] one step in that direction.
  3. Change move so it only moves the player if is_open() says the new square is open. Otherwise print Rubble blocks the way.
  4. Make a visited grid the same size as city, full of False, using a nested loop. Set a square to True when the player arrives.
  5. Add a map command that draws @ for you, # for rubble, . for visited squares and ? for the rest.
  6. Add a Hunter with its own row and column. After every move, it takes one random step (only onto open squares, never the Bunker). If it lands on you: lose 40 health and send it back to where it started.
  7. If the Hunter is 2 or fewer squares away, print a warning. A scan command prints the exact distance.
  8. Reaching the Bunker ends the game (for now).

Here's the minimap. Writing next_position() and the Hunter is up to you.

def draw_map(city, visited, player_row, player_col):
    print("Map:  @ you   . visited   ? unknown   # rubble")
    for row in range(len(city)):
        line = ""
        for col in range(len(city[row])):
            if row == player_row and col == player_col:
                line = line + "@ "
            elif city[row][col] == "Rubble":
                line = line + "# "
            elif visited[row][col]:
                line = line + ". "
            else:
                line = line + "? "
        print(line)
> move north
You move north to the Shops.
Your scanner crackles. The Hunter is close.
> move north
You move north to the Market.
Your scanner crackles. The Hunter is close.
> map
Map:  @ you   . visited   ? unknown   # rubble
? ? # ? ?
# ? ? # ?
? # ? @ ?
? ? # . #
# ? ? . ?
> scan
Hunter signal at range 1.

Done when you can walk to the Bunker and the Hunter can catch you.

Exam corner

On paper, about 15 minutes

Use the city grid from the lesson table.

  1. What is city[2][3]? What is city[0][4]?
  2. Write the [row][col] of every Rubble square.
  3. The player is at [2][2] and the Hunter is at [0][4]. What's the distance?
  4. Complete this pseudocode so it counts the Rubble squares:
count = 0 for row = 0 to 4 for col = 0 to ____ if city[____][____] == "Rubble" then count = ____ endif next col next row print(count)

Done when all four are answered. Check question 4 with code.

Solo mission

Stretch yourself

Real hunters don't wander. When you're 3 or fewer squares away, make the Hunter step towards you instead of moving at random. Compare rows first: if its row is bigger than yours, it moves north. If the rows match, compare columns. It still can't walk through rubble.

Done when the Hunter chases you.

Know your enemy

Your code is filling up with loose variables. Dictionaries turn them into records you can loop over.

Your mission

Every place gets a record: a description, an item and whether it's safe. The player becomes a record. Three machines, the Hunter, the Drone and the Sentry, become a list of records, each with its own position and damage. New commands: look, take, use battery, use medkit, kit and a scan that lists every machine in range.

New this week

  • Dictionaries: keys and values
  • Reading, changing and adding values
  • A list of dictionaries
  • Functions that change a dictionary

You'll reuse

  • The grid and is_open()
  • for loops over lists
  • append() and in
  • f-strings

Records with dictionaries

Right now your player is spread over name, health, energy, player_row... A dictionary keeps them together. Each value has a name, called a key.

player = {"name": "Kai", "health": 100, "energy": 12, "row": 4, "col": 3, "kit": ["torch"]}

print(player["health"])                    # 100
player["energy"] = player["energy"] - 1    # change a value
player["kit"].append("battery")            # the value can be a list
player["turns"] = 0                        # add a new key

A dictionary that describes one thing like this is a record. Exam papers use the word record too.

A dictionary of records

Places can be stored by name. The grid still says where each place is. The dictionary says what's there.

places = {
    "Clinic": {"description": "An abandoned clinic.", "item": "medkit", "safe": False},
    "Shops":  {"description": "A row of looted shops.", "item": "battery", "safe": False},
    "Bunker": {"description": "A steel blast door.", "item": "", "safe": True},
}

name = city[player["row"]][player["col"]]   # e.g. "Shops"
here = places[name]                          # the Shops record
print(here["description"])

A list of records

Three robots means three records in a list. One loop handles them all, and a fourth robot is just one more line of data.

robots = [
    {"model": "Hunter", "row": 2, "col": 2, "start_row": 2, "start_col": 2, "moves": True, "damage": 40},
    {"model": "Drone", "row": 0, "col": 4, "start_row": 0, "start_col": 4, "moves": True, "damage": 15},
    {"model": "Sentry", "row": 3, "col": 1, "start_row": 3, "start_col": 1, "moves": False, "damage": 25},
]

for robot in robots:
    print(robot["model"], "does", robot["damage"], "damage")

Functions can change a dictionary

When you pass a dictionary into a function, the function works on the same dictionary, not a copy. So it can change it without returning anything:

def check_robots(player, robots):
    for robot in robots:
        distance = abs(player["row"] - robot["row"]) + abs(player["col"] - robot["col"])
        if distance == 0:
            print(f"The {robot['model']} catches you! -{robot['damage']} health.")
            player["health"] = player["health"] - robot["damage"]
            if robot["moves"]:
                robot["row"] = robot["start_row"]
                robot["col"] = robot["start_col"]

Inside an f-string that uses double quotes, write keys with single quotes: f"{robot['model']}".

Try it

Your own record, about 10 minutes

me = {"callsign": "Rust Fox", "health": 100, "kit": ["torch"]}
print(me["health"])
me["health"] = me["health"] - 15
me["kit"].append("battery")
me["energy"] = 12
print(me)
print("energy" in me)
100
{'callsign': 'Rust Fox', 'health': 85, 'kit': ['torch', 'battery'], 'energy': 12}
True

Now add print(me["Health"]) with a capital H. Read the error. What is it telling you?

Done when you can explain the error.

Main mission

Records for everything, about 2 hours

  1. Turn the player into a player dictionary. Update every line that used the old variables. Run the game after each change.
  2. Make a places dictionary with a record for every place in your grid. Put a battery in two or three places and a medkit in one. The Bunker and Safehouse are "safe": True.
  3. look prints the place's name, its description, any item, and its open exits.
  4. take moves the item from the place into player["kit"] and sets the place's item to "".
  5. use battery adds 6 energy; use medkit adds 30 health, but never above 100. Using an item removes it from the kit with .remove().
  6. Replace your Hunter variables with the robots list. Moving robots take a random step each turn and never enter a safe place. The Sentry doesn't move.
  7. Use check_robots() from the lesson after the robots move. Warn when a moving robot is 2 or fewer squares away.
  8. scan lists every robot within range 3.
> move north
Shops: A row of looted shops.
You spot a battery.
Exits: north south
Your scanner crackles. A Hunter is close.
> take
You take the battery.
> kit
Kit: ['torch', 'battery']
> use battery
You clip in the battery. +6 energy.
> scan
Scanner sweep:
- Hunter at range 1
- Drone at range 3
- Sentry at range 2

Done when all three robots work and you can pick up and use items.

Exam corner

Records on paper, about 15 minutes

Exam boards describe records like this. Read it, then answer the questions.

record Robot model : string row : integer col : integer damage : integer endrecord drone = Robot("Drone", 0, 4, 15)
  1. Name the four fields and their data types.
  2. Write one line of pseudocode that moves drone one square south.
  3. Why is a record better than four separate variables when you have 20 robots?
  4. Predict what this Python prints: r = {"model": "Sentry", "damage": 25}, then r["damage"] = r["damage"] * 2, then print(r).

Done when all four are answered.

Solo mission

Stretch yourself

Add a fourth machine of your own design with a field the others don't have, such as "sight" (how far away it can spot you) or "speed" (squares per turn). Use the field in your code. Then add a smoke grenade item: using it sends every robot back to its start.

Done when your new machine uses its new field.

Hack the machine

Seal the bunker with a code word that only WARDEN's scrambled transmission can reveal. After this week, your game can be won.

Your mission

A speaker at the Radio Mast repeats an intercepted machine message, scrambled with a Caesar cipher. A clue somewhere else tells the player the shift. decode 6 tries shift 6. At the Bunker, unlock followed by the code word opens the door and wins the game. Add proper endings: one for escaping, one for when health or energy runs out.

Milestone: after this week, someone else should be able to play your game from start to finish.

New this week

  • Looping through a string
  • ord() and chr()
  • % (MOD)
  • Slicing: text[start:end]
  • .isdigit() and .upper()

You'll reuse

  • Functions that return
  • Place records
  • len()
  • The game loop and break

Letters are numbers in disguise

A Caesar cipher moves every letter along the alphabet by the same amount, the shift. With shift 3, A becomes D and HELLO becomes KHOOR. To decode, move back by the same shift.

Computers store each letter as a number. ord() gives you the number and chr() turns it back into a letter.

ord("A")              # 65
ord("D")              # 68
chr(72)               # "H"
ord("D") - ord("A")   # 3: D is letter 3 if A is letter 0

Wrapping round with %

Shifting Y forward by 3 should land on B, not fall off the end. % gives the remainder after dividing (exams call it MOD). % 26 wraps any number back into 0 to 25.

(24 + 3) % 26    # 1, which is B
(1 - 3) % 26     # 24, which is Y: it wraps backwards too

Building a new string

A for loop can walk through a string one character at a time. Start with an empty string and add to it:

result = ""
for letter in "ROBOT":
    result = result + letter + "."
print(result)    # R.O.B.O.T.

Slicing

Square brackets with a colon cut out part of a string. text[start:end] starts at start and stops before end. Leave a number out to go from the beginning or to the end.

message = "MSG-0417:ZNK JUUX IUJK OY KSHKX"
message[:8]     # "MSG-0417"   characters 0 to 7
message[9:]     # "ZNK JUUX IUJK OY KSHKX"   from character 9 to the end
len(message)    # 31

Try it

In the Shell, about 10 minutes

Predict, then check each one:

print(ord("A"))
print(ord("D"))
print(chr(72))
print(27 % 26)
print("DEAD GRID"[0:4])
print("DEAD GRID"[5:])
print(len("DEAD GRID"))
65
68
H
1
DEAD
GRID
9

Then work out on paper what chr(ord("Z") - 25) gives, and check it.

Done when your predictions match.

Main mission

The cipher, the door and the endings, about 2 hours

From this week, you get less finished code. Fill the gaps marked ____.

def decrypt(text, shift):
    result = ""
    for letter in text:
        if letter >= "A" and letter <= "Z":
            number = ord(letter) - ord("A")
            number = (number - ____) % 26
            result = result + chr(____)
        else:
            result = result + ____
    return result
  1. Finish decrypt(). Test it in the Shell: decrypt("KHOOR", 3) should give HELLO.
  2. Write encrypt() (the same, but adding the shift). Use it once to scramble your own message, such as THE DOOR CODE IS EMBER. Paste the scrambled text into your game as a constant, with a header in front: TRANSMISSION = "MSG-0417:..."
  3. Give the Radio Mast record a "message" key. When the player looks there, slice off the header, print it and the scrambled body, and store the body in the player record.
  4. Hide the shift as a clue in another place's description.
  5. Add decode <shift>. Use .isdigit() to check the shift is a number before you call int().
  6. Give the Bunker a "locked": True key. unlock <code word> only works at the Bunker. The right word wins the game.
  7. Write show_ending(player, escaped): a winning message with the number of turns, or a losing one. Health never shows below 0.

The unlock command, in pseudocode:

if the player is not at the Bunker then print "There's nothing to unlock here." elseif no word was typed then print "Unlock with what?" elseif the word in capitals == DOOR_CODE then unlock the Bunker, escaped = True else print "ACCESS DENIED", the turn is used up endif
> move west
Radio Mast: A toppled radio mast. A speaker still crackles with machine chatter.
The speaker repeats intercept MSG-0417 (22 characters):
  ZNK JUUX IUJK OY KSHKX
It's scrambled. Try: decode <shift>
> decode 3
Shift 3: WKH GRRU FRGH LV HPEHU
> decode 6
Shift 6: THE DOOR CODE IS EMBER
...
> unlock ember
========================================
The blast door groans open. Hands pull you inside.
Kai reached the resistance in 17 turns.
Health left: 70   Energy left: 9
========================================

Done when someone else has played your game to the end, without your help.

Exam corner

By hand, about 15 minutes

  1. Encrypt ROBOT with a shift of 4.
  2. Decrypt WIGVIX, which was encrypted with a shift of 4.
  3. Exams use MOD for the remainder and DIV for whole-number division. Work out 17 MOD 5, 17 DIV 5 and (24 + 5) MOD 26.
  4. Why is a Caesar cipher easy to crack? How many shifts would you need to try at most?

Done when you've answered all four. Check 1 and 2 with your code.

Solo mission

Stretch yourself

Add a crack command that tries every shift from 1 to 25 and prints them all, so a player who missed the clue can still find the message (it costs 5 energy). This is called a brute force attack.

Done when crack reveals the message.

Safehouse

Survivors need to rest. Save progress to a file at the safehouse and carry on next time.

Your mission

At the Safehouse, save writes the player record to a text file, one value per line. When the game starts, a menu offers New game or Load game. Loading reads the file back. A missing or damaged save file must never crash the game.

New this week

  • Writing and reading files
  • with open(...) as file:
  • ",".join() and .split(",")
  • try and except
  • None

You'll reuse

  • The player record
  • int() conversion
  • f-strings
  • A validated menu

Files remember

Variables vanish when the program stops. A file stays on the disk.

with open("deadgrid_save.txt", "w") as file:
    file.write(f"{player['name']}\n")
    file.write(f"{player['health']}\n")

"w" means write: it creates the file, or replaces it. "a" means append: it adds to the end. With no letter, the file opens for reading. \n is a new line. Without it, everything ends up on one line. The with block closes the file for you when it ends.

Reading back

with open("deadgrid_save.txt") as file:
    lines = file.read().splitlines()

name = lines[0]
health = int(lines[1])   # everything comes back as text

Lists in one line

",".join(kit) turns ["torch", "battery"] into "torch,battery". .split(",") turns it back. One trap: "".split(",") gives [""], a list with one empty item, not an empty list. An empty kit needs its own check.

When things go wrong

Opening a file that isn't there raises FileNotFoundError. A damaged file might make int() fail with ValueError, or be too short (IndexError). try runs code that might fail. except catches a named error and runs something else instead of crashing.

try:
    with open("deadgrid_save.txt") as file:
        lines = file.read().splitlines()
    health = int(lines[1])
except FileNotFoundError:
    print("No save file found.")
except (ValueError, IndexError):
    print("The save file is damaged.")

A function that can't produce a player can return None, Python's value for "nothing". Test for it with if player is None:.

Try it

Write and read a file, about 15 minutes

with open("test_save.txt", "w") as file:
    file.write("Kai\n")
    file.write(f"{100}\n")
    file.write(",".join(["torch", "battery"]) + "\n")

with open("test_save.txt") as file:
    lines = file.read().splitlines()

print(lines)
health = int(lines[1])
kit = lines[2].split(",")
print(health - 10)
print(kit)
print("".split(","))
['Kai', '100', 'torch,battery']
90
['torch', 'battery']
['']

Open test_save.txt in your editor and look at it. Then delete the second line, save it, and run only the reading part. Which error do you get?

Done when you've seen the file and the error.

Main mission

Save and load, about 2 hours

Use this file layout, one value per line:

LineHoldsExample
0nameKai
1–2row, col4, 4
3–5health, energy, turns100, 9, 3
6kittorch,battery
7places you've emptiedShops
8the intercepted message(blank until you've heard it)
  1. Add "emptied": [] to the player. When you take an item, add the place's name to it. (Otherwise items reappear when you load.)
  2. Write split_list(text): return [] for an empty string, otherwise text.split(",").
  3. Write save_game(filename, player). The save command only works at the Safehouse.
  4. Write load_game(filename). It returns a player record, or None if the file is missing or damaged.
  5. At the start: a New game / Load game menu. After loading, empty the places listed in "emptied". If loading fails, start a new game.
def load_game(filename):
    try:
        with open(filename) as file:
            lines = file.read().splitlines()
        player = {
            "name": lines[0],
            "row": int(lines[1]),
            # ...the rest of the fields, in the same order you saved them...
            "kit": split_list(lines[6]),
        }
        return player
    except FileNotFoundError:
        print("No save file found.")
        return None
    except ____:
        ____

Done when you can save, quit, reload and carry on. Also check that a broken file doesn't crash it.

Exam corner

File pseudocode, about 15 minutes

Exam pseudocode for files often looks like this (check with your teacher which exam board you'll sit):

myFile = openWrite("scores.txt") myFile.writeLine("Kai") myFile.close() myFile = openRead("scores.txt") name = myFile.readLine() myFile.close()
  1. Write pseudocode that saves a survivor's name and health on two lines.
  2. Write pseudocode that reads them back and adds 10 to the health.
  3. Give two reasons reading a save file might fail, and what your program does for each.

Done when all three are written.

Solo mission

Stretch yourself

Make the game save automatically every time you walk into the Safehouse. Then save the robots' positions too, so they're where you left them after loading.

Done when robots load where you left them.

Trust no one

The machines have learnt to copy human voices. Lock the game behind survivor profiles, and make every input crash-proof.

Your mission

Before playing, survivors log in with a callsign and a passphrase, or create a new profile. Profiles are kept in profiles.txt. Three wrong attempts means you're locked out. Each survivor gets their own save file. Then replace your hand-written validation loops with two reusable functions, get_choice() and get_number().

Only ever use made-up passphrases here. This game stores them as plain text, which real systems never do. Real systems store a scrambled version called a hash.

New this week

  • Defensive design
  • Types of validation
  • Authentication
  • try/except ValueError
  • Moving the game loop into a function

You'll reuse

  • Files and "a" mode
  • Dictionaries
  • .isdigit() and len()
  • for loops with range()

Defensive design

Defensive design means expecting people to type the wrong thing, by accident or on purpose, and planning for it.

  • Validation checks that input is sensible: the right type, length, range or format. It can't tell whether the input is true.
  • Authentication checks that someone is who they say they are, for example with a passphrase.
CheckQuestion it asksIn Dead Grid
PresenceWas anything typed?An empty command
TypeIs it the right kind of data?The shift must be a whole number
RangeIs it between the limits?Shift from 1 to 25
LengthIs it long enough, or too long?Callsign 3 to 12 characters
FormatDoes it follow a pattern?Passphrase includes a number, no commas

One function, every menu

Instead of writing a new validation loop for every menu, write it once:

def get_number(prompt, low, high):
    while True:
        text = input(prompt).strip()
        try:
            number = int(text)
            if number >= low and number <= high:
                return ____
            print(f"Enter a number from {low} to {high}.")
        except ____:
            print("That's not a number.")

return inside a loop ends the function straight away, so the loop stops too.

Logging in

for attempt = 0 to 2 callsign = input("Callsign") passphrase = input("Passphrase") if callsign is in profiles AND profiles[callsign] == passphrase then print("Identity confirmed") return callsign endif print("Identity not confirmed. Attempts left: ", 2 - attempt) next attempt print("Lockout") return ""

The message says identity not confirmed, not wrong passphrase. Never tell an attacker which half they got right.

Try it

Attack a validator, about 15 minutes

Finish get_number() from the lesson in a new file and add:

shift = get_number("Shift (1-25): ", 1, 25)
print("You chose", shift)

Run it and type seven, then 0, then 26, then 6.

Shift (1-25): seven
That's not a number.
Shift (1-25): 0
Enter a number from 1 to 25.
Shift (1-25): 26
Enter a number from 1 to 25.
Shift (1-25): 6
You chose 6

Done when all four inputs behave like this.

Main mission

Profiles and a crash-proof game, about 2 hours

  1. Write get_choice(prompt, options) and get_number(prompt, low, high). Use them for every menu and for decode.
  2. Move your game loop into def play(player):. This lets the program stop cleanly when someone can't log in.
  3. Write username_ok(callsign): 3 to 12 characters, letters and numbers only (try .isalnum()).
  4. Write passphrase_ok(passphrase): at least 8 characters, at least one digit, no commas. You'll need a loop to look for a digit.
  5. Write load_profiles(filename). It returns a dictionary of callsign: passphrase. A missing file just means no profiles yet.
  6. Write create_profile(profiles). It asks for a callsign until it's valid and not taken, then a passphrase until it's strong enough, then the passphrase again to confirm. Add the profile to the file with "a" mode.
  7. Write log_in(profiles) from the pseudocode in the lesson.
  8. A start menu: log in, create a profile or quit. Each survivor saves to f"save_{callsign}.txt".

Part of passphrase_ok():

def passphrase_ok(passphrase):
    if len(passphrase) < 8 or "," in passphrase:
        return False
    for character in passphrase:
        if ____:
            return True
    return ____

Done when two different survivors can log in and keep separate saves.

Exam corner

Defensive design, about 15 minutes

  1. For each input, name the validation check you'd use: a callsign; the number of players (1 to 4); a date of birth; a postcode; a required email address.
  2. Explain the difference between validation and authentication, using an example from Dead Grid.
  3. Write pseudocode that keeps asking for a shift until it's a number from 1 to 25.
  4. Give two ways to make a login system more secure than yours.

Done when all four are answered.

Solo mission

Stretch yourself

Store a hash instead of the passphrase. Python's hashlib can do it: hashlib.sha256(passphrase.encode()).hexdigest(). When someone logs in, hash what they typed and compare the two hashes. Look in profiles.txt afterwards: can you still read the passphrases?

Done when profiles.txt holds hashes, not passphrases.

The registry

WARDEN keeps a registry of every machine it controls. Hack into it, find a robot's weakness and switch it off.

Your mission

The registry is a file of 500 robots, sorted by serial number. When a robot is right next to you, scan shows its serial plate. At the Archive terminal, lookup WD-0951 searches the registry twice, once with a linear search and once with a binary search, and reports how many checks each took. Once you know a robot's weakness, disable switches it off when it's within reach.

Download registry.txt Save it in the same folder as your game.

New this week

  • Linear search
  • Binary search
  • Why binary search needs sorted data
  • // (DIV)
  • Returning a dictionary of results

You'll reuse

  • Reading files and try/except
  • Lists of lists
  • while loops
  • Robot records

Two ways to find something

Each line of registry.txt looks like WD-0951,Sentry,Cover the sensor eye and it shuts down. Split each line at the commas and you get a list: serial, model, weakness.

Linear search

Check the first item, then the next, then the next, until you find it or run out. Simple, and it works on any list, sorted or not. In the worst case it checks every item: 500 checks for 500 robots.

function linearSearch(items, target) for i = 0 to items.length - 1 if items[i] == target then return i endif next i return -1 endfunction

Binary search

Think of guessing a number between 1 and 100 when someone says "higher" or "lower". You'd start at 50, not at 1. Binary search does the same: look at the middle item. If it's too small, throw away the left half. If it's too big, throw away the right half. Repeat. Every check halves what's left, so 500 robots take at most 9 checks.

function binarySearch(items, target) low = 0 high = items.length - 1 while low <= high middle = (low + high) DIV 2 if items[middle] == target then return middle elseif items[middle] < target then low = middle + 1 else high = middle - 1 endif endwhile return -1 endfunction

DIV is whole-number division. In Python it's //, so (0 + 9) // 2 is 4.

Binary search only works if the list is sorted. The serials have leading zeros (WD-0951, not WD-951) so they sort correctly as text.

Try it

Linear search on numbers, about 15 minutes

Turn the linear search pseudocode into a Python function and test it:

numbers = [3, 8, 12, 19, 25, 31, 40]
print(linear_search(numbers, 19))
print(linear_search(numbers, 3))
print(linear_search(numbers, 99))
3
0
-1

Done when all three are right, including the missing number.

Main mission

Hack the registry, about 2 hours

This week there's no Python to copy, only the pseudocode above. You know enough to translate it.

  1. load_registry(filename) returns a list of [serial, model, weakness] lists. If the file is missing, warn the player and return an empty list.
  2. Give every robot a "serial" (use WD-7314 for the Hunter, WD-2208 for the Drone and WD-0951 for the Sentry) and "active": True.
  3. scan shows a robot's serial plate when it's 1 square away or closer.
  4. Write linear_search(registry, serial) and binary_search(registry, serial). Each returns a dictionary: {"index": ..., "checks": ...}. Compare against registry[i][0], the serial in each row.
  5. Give the Archive record "terminal": True. lookup <serial> only works there. It runs both searches, prints both check counts, then the robot's model and weakness, and adds the serial to player["known"].
  6. disable switches off an active robot within 1 square if its serial is in player["known"]. It costs 2 energy. Inactive robots don't move or attack.
  7. Save player["known"] in the save file too.
> scan
Scanner sweep:
- Sentry at range 1. Serial plate: WD-0951
...
> lookup wd-0951
Binary search: 5 checks. Linear search: 46 checks.
WD-0951 | Model: Sentry | Weakness: Cover the sensor eye and it shuts down
...
> disable
You use what the registry told you. The Sentry powers down.

Done when you can disable the Sentry and both searches report their checks.

Exam corner

Trace a binary search, about 20 minutes

The list has 11 items, at positions 0 to 10:

[3, 8, 12, 19, 25, 31, 40, 47, 52, 60, 71]

Trace a binary search for 47, then for 10. Add a row each time middle is worked out.

lowhighmiddleitems[middle]what happens
01053131 < 47, so low = 6

Then: how many checks would a linear search need to find 47? Why can't you binary search the registry by model?

Done when both traces are done.

Solo mission

Stretch yourself

Write a short program that makes a registry of 50,000 robots (random serials, sorted, written to a file). Search it for the last serial with both methods. How many checks does each need now? Write down what you notice about how each one grows.

Done when you've compared 500 and 50,000.

Hall of survivors

Every escape goes on the record. Rank survivors by who escaped in the fewest turns, using a sort you write yourself.

Your mission

When someone escapes, add callsign,turns to hall.txt. After logging in, a new menu offers Play, Hall of survivors or Quit. The hall shows the top five escapes, fewest turns first. Sort them with your own bubble sort, not Python's sorted(). Then write an insertion sort and check it agrees.

New this week

  • Bubble sort
  • Insertion sort
  • Merge sort (on paper)
  • Swapping two items
  • Sorting records by one field

You'll reuse

  • Append mode for files
  • Lists of lists
  • while loops with a flag
  • get_choice()

Putting things in order

Bubble sort

Walk along the list comparing each pair of neighbours. If they're in the wrong order, swap them. One walk is a pass. After the first pass, the biggest item has "bubbled" to the end. Keep doing passes until one pass makes no swaps.

swapped = True while swapped swapped = False for i = 0 to items.length - 2 if items[i] > items[i + 1] then temp = items[i] items[i] = items[i + 1] items[i + 1] = temp swapped = True endif next i endwhile

Why temp? If you write items[i] = items[i + 1] first, the old items[i] is gone. temp holds it for a moment.

Insertion sort

Like sorting playing cards in your hand. Take the next card and slide it left past every bigger card until it's in the right place. Everything to its left is already sorted.

for i = 1 to items.length - 1 current = items[i] position = i while position > 0 AND items[position - 1] > current items[position] = items[position - 1] position = position - 1 endwhile items[position] = current next i

Merge sort

Split the list in half again and again until every piece has one item. Then merge pairs of pieces back together, in order, until you have one sorted list. It's much faster on big lists. You only need to do it on paper for the exam.

      [6, 1, 8, 3]
     /            \
  [6, 1]        [8, 3]         split
  /    \        /    \
[6]    [1]    [8]    [3]
  \    /        \    /
  [1, 6]        [3, 8]         merge
     \            /
      [1, 3, 6, 8]

Sorting records

Each hall entry is a list like ["Kai", 17]. To sort by turns, compare records[i][1], not the whole record.

Try it

Watch a bubble sort, about 15 minutes

Turn the bubble sort pseudocode into Python. Sort numbers = [5, 2, 8, 1, 4] and print the list at the end of every pass.

[2, 5, 1, 4, 8]
[2, 1, 4, 5, 8]
[1, 2, 4, 5, 8]
[1, 2, 4, 5, 8]

Why is there a fourth pass when the list was already sorted after the third?

Done when your passes match and you can answer the question.

Main mission

The hall of survivors, about 1–2 hours

  1. Make play() return True if the player escaped and False otherwise.
  2. record_escape(filename, name, turns) adds a line to hall.txt using "a" mode.
  3. load_hall(filename) returns a list of [name, turns] with turns as a number. Skip any broken lines. A missing file means an empty hall.
  4. bubble_sort(records) sorts by turns, fewest first.
  5. show_hall(filename) prints the top five with their rank, or a message if nobody has escaped yet.
  6. After logging in, show a menu: Play, Hall of survivors, Quit. After an escape, record it and show the hall.
  7. Write insertion_sort(records) and check it gives the same order as your bubble sort.
========== HALL OF SURVIVORS ==========
1. Ro - 17 turns
2. Tam - 17 turns
3. Jo - 19 turns
4. Ash - 22 turns
5. Mo - 31 turns
=======================================

Done when the hall shows at least five escapes in the right order.

Exam corner

Sorts on paper, about 20 minutes

  1. Show the list after every pass of a bubble sort on [7, 3, 9, 2].
  2. Show the list after each card is inserted in an insertion sort on [7, 3, 9, 2].
  3. Draw the split and merge diagram for a merge sort on [6, 1, 8, 3, 5, 2, 7, 4].
  4. Give one advantage of bubble sort and one advantage of merge sort.

Done when all four are done.

Solo mission

Stretch yourself

Write merge sort in Python. Then break ties: when two survivors took the same number of turns, the one with more health left ranks higher. (You'll need to save health in the hall file too.)

Done when ties are broken by health.

Last transmission

Stop adding features. Test it, tidy it, and release Dead Grid 1.0 to someone who has never seen it.

Your mission

Write a test plan. Add automated tests that check your functions for you. Tidy the code so someone else could work on it. Watch a new player try the game without helping them, fix what trips them up, and write release notes. Then show it off at the final show and tell.

New this week

  • Test plans: normal, boundary, erroneous
  • assert
  • if __name__ == "__main__":
  • Constants
  • Maintainability and refactoring

You'll reuse

  • Everything

Prove it works

A test plan chooses test data on purpose, rather than just playing and hoping.

TypeMeaningDead Grid example
NormalSensible data that should be acceptedShift 6
BoundaryRight at the edge of what's allowed, and just past itShift 1 and 25; 0 and 26
ErroneousThe wrong type or format, which should be rejectedShift six

Iterative testing happens while you build, after every change. Final testing happens at the end, against the whole plan.

Tests that run themselves

assert checks that something is True. If it isn't, Python stops and shows you which check failed.

assert decrypt("KHOOR", 3) == "HELLO"
assert split_list("") == []
assert not passphrase_ok("abcdef1")    # 7 characters: too short

Keep tests in a separate file that imports your game. There's a catch: importing a file runs all of it, so the game would start. Put your main program in a main() function and add this at the very bottom:

if __name__ == "__main__":
    main()

That line means "only run main() if this file was run directly, not imported".

Code someone else can work on

  • Constants in capitals at the top for numbers you might tune: START_ENERGY = 12, BATTERY_ENERGY = 6.
  • Meaningful names: battery_energy, not b.
  • Comments that explain why, not what.
  • Functions that each do one job, with no repeated blocks of code.

Try it

Break your own code on purpose, about 15 minutes

Write five assert lines for functions you already have, in a file called tests.py next to your game. Add main() and the if __name__ == "__main__": line to your game so importing it doesn't start it. Run the tests. Then break one function on purpose (change a < to <=, say) and run them again.

Done when you've seen a test pass and a test fail.

Main mission

Release Dead Grid 1.0

  1. Test plan. A table with at least 12 rows: what you're testing, test data, type (normal, boundary or erroneous), expected result, actual result. Cover moving off the map, the shift, the callsign and passphrase rules, loading a broken save and searching for a missing serial.
  2. Automated tests. At least 15 assert checks in tests.py, including boundary values either side of every rule.
  3. Tidy up. Constants for balance numbers, a main() function, comments where the reason isn't obvious, nothing repeated.
  4. Silent playtest. Someone who's never played it plays while you watch and say nothing. Write down where they got stuck. Fix the three biggest problems.
  5. Release notes. Version number, what the game is, how to play, what you fixed after the playtest, and anything you know still doesn't work.
ok  decrypt shifts letters back
ok  decrypt wraps from A back to Z
ok  callsign of 2 characters rejected
ok  callsign of 3 characters accepted
...
ok  sorting an empty list
All tests passed.

Done when all five are done.

Exam corner

Testing and maintainability, about 15 minutes

  1. A passphrase must be 8 to 20 characters. Write one normal, two boundary and two erroneous test values, each with its expected result.
  2. Explain the difference between iterative and final testing.
  3. Find four features in your own code that make it easier to maintain. Give the line number of each.
  4. What's the difference between a syntax error and a logic error? Give an example of each from this course.

Done when all four are answered.

Solo mission

Stretch yourself

Add a difficulty setting at the start. Easy, normal and hard change your constants: starting energy, robot damage, battery strength. Playtest all three and make sure hard is beatable.

Done when all three difficulties can be won.