Session 14 of the Python Fundamentals series, and the second part of the capstone. You finish the adventure game by giving it state that travels through the play-through: a dictionary holding items and a score, rooms that read and update it, a locked door that checks for a key, and real winning and losing endings chosen from the final state. You then polish it with an ask helper and invalid-choice handling. The traps are forgetting to pass the state, so that changes are lost, and reassigning the state dictionary instead of updating it. Every snippet was verified under CPython 3.12, with interactive input shown as the sequence the player types.
Subject: Python Fundamentals · 94 slides · code lesson
Open the interactive version of this deck · Homework for this lesson
Title
Python Fundamentals - Session 14
Part 2 - state, endings, and polish
Objectives
Part 1 built the skeleton and a couple of rooms. Now we complete and polish it. By the end you can:
Warm-up
Discussion prompt
Before we open Session 14 - Capstone: Adventure Game (Part 2): without looking back, what was the main idea of Session 13 - Capstone: Adventure Game (Part 1), and what could you do by the end of it that you could not do before?
Hint: One sentence for the idea, one for the skill. If the second one is blank, that is the part to revisit.
Answer:
That session plans the map, models rooms as functions that return the next room, and drives them with a while-loop state machine.
Section
Part 1
Concept
A real game remembers things across rooms: an item picked up, a score, whether a switch was flipped.
That remembered information is the game's state, and it must survive from room to room.
Counterexample
Discussion prompt
A real game remembers things across rooms: an item picked up, a score, whether a switch was flipped.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Concept
Keep the state in a variable that the engine passes into each room, so any room can read or change it.
Rooms come and go, but the state variable persists for the whole play-through.
Analogy
Discussion prompt
Explain State travels with the player by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
Keep the state in a variable that the engine passes into each room, so any room can read or change it.
Intuition
Picture a backpack the player carries everywhere. Rooms can put things in it (a key) or read from it (do I have the key?).
The room changes; the backpack stays. That is exactly what a passed-in state variable is.
Explain it
Discussion prompt
Explain State is the player's backpack to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Picture a backpack the player carries everywhere. Rooms can put things in it (a key) or read from it (do I have the key?).
Concept
Give rooms that need it a state parameter: def room_cave(state):. The engine passes the same state object each call.
Because it is the same object, changes a room makes are visible everywhere - the in-place idea from the scope session.
Section
Part 2
Concept
A dictionary is a natural fit for state: keys name what you track, values hold the current status.
state = {"has_key": False, "score": 0} - a flag and a number, all in one place.
Explain it to yourself
Discussion prompt
In Setting up state this move is made:
Read the output
Why is that legal? Name the rule or definition it rests on before you read on.
Hint: If you can only say "because that is what you do", the rule is the thing to go and find.
Answer:
Verified by execution: False then 0 - the starting state.
Worked example
state = {"has_key": False, "score": 0}
print(state["has_key"])
print(state["score"])One dict, many pieces of state
Why: has_key tracks an item; score tracks points.
Read the output
Why: Verified by execution: False then 0 - the starting state.
| key | start value |
|---|---|
| has_key | False |
| score | 0 |
Comparison
Comparison matrix
From Setting up state: refill the start value column from what you know. The rest of the table is as it appeared.
| key | start value |
|---|---|
| has_key | False |
| score | 0 |
Concept
Inside a room, change the dict in place: state["has_key"] = True. That update sticks because every room shares the same dict.
Fill the middle
Fill in the blanks
From Grabbing a key — one line has had its right-hand side removed. Put it back.
def room_cave(state):
print("A shiny key lies on a rock. (grab) or (leave)?")
c = input("> ").strip().lower()
if c == "grab":
state["has_key"] = True
print("You take the key.")
return "door"
Why: state["has_key"] is what everything below it consumes, so the wrong expression here fails later and somewhere else. state["has_key"] = True updates the dict every room can see.
Worked example
The player types grab:
def room_cave(state):
print("A shiny key lies on a rock. (grab) or (leave)?")
c = input("> ").strip().lower()
if c == "grab":
state["has_key"] = True
print("You take the key.")
return "door"grab sets the flag in the shared state
Why: state["has_key"] = True updates the dict every room can see.
The room then sends the player to the door
Why: Verified by execution: grabbing the key sets has_key True, then returns "door".
| choice | has_key after | returns |
|---|---|---|
| grab | True | door |
| leave | False | door |
Trade off
Comparison matrix
From Grabbing a key: every row here is a choice with a cost. Fill the has_key after column, then say which row you would actually pick and what you give up for it.
| choice | has_key after | returns |
|---|---|---|
| grab | True | door |
| leave | False | door |
Anomaly
Predict first
A student writes this, and it looks reasonable:
A room makes its own local state instead of using the shared one.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: A brand-new dict is made each call and vanishes when the room returns.
Take state as a parameter and update that.
Why: A brand-new dict is made each call and vanishes when the room returns. The door room never sees the key.
Trap
A room makes its own local state instead of using the shared one.
def room_cave():
state = {"has_key": False}
state["has_key"] = True
return "door"This state is local and thrown away
Why: A brand-new dict is made each call and vanishes when the room returns. The door room never sees the key.
| where | has_key |
|---|---|
| inside room_cave | True (local) |
| everywhere else | unchanged |
Take state as a parameter and update that.
def room_cave(state):
state["has_key"] = True
return "door"The shared dict is updated in place
Why: Now the change persists to the door room. Pass the one state object into every room that touches it.
| where | has_key |
|---|---|
| after room_cave(state) | True everywhere |
Error analysis
Annotate
Walk the callouts on Trap: state not passed, changes lost. Each one is a place this is easy to get subtly wrong.
Section
Part 3
Concept
A room can branch on state: if state["has_key"]:. The same room leads to different places depending on what the player carries.
Worked example
def room_door(state):
print("A locked door blocks the exit.")
if state["has_key"]:
print("Your key fits! You escape.")
return "win"
print("It will not budge.")
return "lose"The door checks the backpack
Why: If has_key is True, the player wins; otherwise they lose.
State decides the outcome
Why: Verified by execution: with the key, returns "win"; without it, returns "lose".
| has_key | returns |
|---|---|
| True | win |
| False | lose |
Blank canvas
Draw it
Draw what The locked door just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Concept
Because the door reads state set earlier, the player's choice in the cave changes the ending. That connection is what makes a game feel real.
Sorting
Sort into buckets
These are the pieces of Session 14 - Capstone: Adventure Game (Part 2), out of order. Put each one back under the part of the lesson it belongs to.
Section
Part 4
Concept
Add a "score" key and adjust it as the player acts: state["score"] += 10 for a good move.
Pattern
Predict first
The table runs: start | 0 · +10 | 10
In Awarding points, given the rows so far: what is the next one — the row where action is +5?
Correct: +5 | 15
| action | score |
|---|---|
| start | 0 |
| +10 | 10 |
| +5 | 15 |
Why: The relationship between the columns, not the individual numbers, is what generates the next row. Each += adds to the running score, just like an accumulator.
Worked example
state = {"score": 0}
state["score"] += 10
state["score"] += 5
print(state["score"])Points accumulate in the dict
Why: Each += adds to the running score, just like an accumulator.
Read the output
Why: Verified by execution: 15.
| action | score |
|---|---|
| start | 0 |
| +10 | 10 |
| +5 | 15 |
Pattern
Step through it
Step through Awarding points one row at a time. What is driving the change, and what would the row after the last one be?
Concept
After the loop, report the final score with an f-string: print(f"Final score: {state['score']}").
Explain it to yourself
Discussion prompt
In The final scoreboard this move is made:
Pull the value into the message
Why is that legal? Name the rule or definition it rests on before you read on.
Hint: If you can only say "because that is what you do", the rule is the thing to go and find.
Answer:
The f-string reads state['score'] and drops it in.
Worked example
state = {"score": 25}
print(f"Final score: {state['score']}")Pull the value into the message
Why: The f-string reads state['score'] and drops it in.
Read the output
Why: Verified by execution: Final score: 25.
| score | prints |
|---|---|
| 25 | Final score: 25 |
Section
Part 5
Concept
Loop while room not in ("win", "lose"):. Either ending name stops the game.
Explain it to yourself
Discussion prompt
In Choosing the ending message this move is made:
Read the output
Why is that legal? Name the rule or definition it rests on before you read on.
Hint: If you can only say "because that is what you do", the rule is the thing to go and find.
Answer:
Verified by execution: with room "win", prints == YOU ESCAPED ==.
Worked example
room = "win"
if room == "win":
print("== YOU ESCAPED ==")
else:
print("== GAME OVER ==")After the loop, pick the closing screen
Why: The final value of room says which ending to show.
Read the output
Why: Verified by execution: with room "win", prints == YOU ESCAPED ==.
| room | prints |
|---|---|
| win | == YOU ESCAPED == |
| lose | == GAME OVER == |
Intuition
A win and a lose ending give the player something to reach for and something to avoid. Even just two endings make a game feel complete.
The stakes come entirely from state: the same rooms end differently based on what the player did.
Concept
Blend both: after the loop, show the win/lose screen and the final score for a satisfying finish.
Section
Part 6
Concept
Put it together: a state dict, room functions that take state, the engine loop, and an ending screen. Everything you have learned, in one program.
Estimation
Predict first
A winning play-through: right, then grab:
Commit before you compute: what does The complete game come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.
Correct: Trace the winning path
Why: A prediction you can defend turns the computation into a check rather than a leap of faith — and an answer that contradicts it is caught on the spot. Verified by execution: right -> cave, grab sets has_key True, door -> win, prints WIN.
Worked example
A winning play-through: right, then grab:
def room_start():
print("A fork. (left) or (right)?")
c = input("> ").strip().lower()
if c == "right":
return "cave"
return "start"
def room_cave(state):
print("A key on a rock. (grab) or (leave)?")
if input("> ").strip().lower() == "grab":
state["has_key"] = True
return "door"
def room_door(state):
if state["has_key"]:
return "win"
return "lose"
state = {"has_key": False}
room = "start"
while room not in ("win", "lose"):
if room == "start":
room = room_start()
elif room == "cave":
room = room_cave(state)
elif room == "door":
room = room_door(state)
print("WIN" if room == "win" else "LOSE")State flows through the rooms
Why: room_cave sets has_key; room_door reads it to decide the ending.
Trace the winning path
Why: Verified by execution: right -> cave, grab sets has_key True, door -> win, prints WIN.
| room | has_key | next |
|---|---|---|
| start | False | cave |
| cave (grab) | True | door |
| door | True | win |
Pattern
Step through it
Step through The complete game one row at a time. What is driving the change, and what would the row after the last one be?
Worked example
The player types right, then leave:
# right -> cave (leave) -> door (no key) -> loseLeaving the key changes the ending
Why: Without has_key, room_door returns "lose".
Read the states
Why: Verified by execution: leaving the key leads to LOSE - the same rooms, a different outcome.
| room | has_key | next |
|---|---|---|
| cave (leave) | False | door |
| door | False | lose |
Comparison
Comparison matrix
From Trace the losing path: refill the next column from what you know. The rest of the table is as it appeared.
| room | has_key | next |
|---|---|---|
| cave (leave) | False | door |
| door | False | lose |
Section
Part 7
Concept
In every room, an unrecognized choice should re-ask (return the same room) rather than silently do nothing or crash.
Estimation
Predict first
The player types dance (invalid), then grab:
Commit before you compute: what does Re-prompt on bad input come out to? A rough magnitude and the right form is enough — the point is to have something concrete to be wrong about.
Correct: Read the flow
Why: A prediction you can defend turns the computation into a check rather than a leap of faith — and an answer that contradicts it is caught on the spot. Verified by execution: dance re-prompts, grab then takes the key and moves on.
Worked example
The player types dance (invalid), then grab:
def room_cave(state):
print("A key. (grab) or (leave)?")
c = input("> ").strip().lower()
if c == "grab":
state["has_key"] = True
return "door"
if c == "leave":
return "door"
print("You can (grab) or (leave).")
return "cave"Unknown choices return the same room
Why: dance matches nothing, so it re-asks the cave.
Read the flow
Why: Verified by execution: dance re-prompts, grab then takes the key and moves on.
| choice | returns |
|---|---|
| dance | cave (re-ask) |
| grab | door |
Concept
A single ask helper (input(prompt).strip().lower()) keeps every room forgiving of spaces and capitals, with no repeated code.
Concept
Vivid descriptions, a score, a clear win/lose screen, and forgiving input turn a skeleton into a game worth showing off.
None of it is new syntax - it is the Phase 1 toolkit, arranged well.
Section
Part 8
Anomaly
Predict first
A student writes this, and it looks reasonable:
Replacing state with a new dict inside a room.
It is wrong. Say what breaks — and say it before you turn the page.
Correct: state = {...} points the local name at a new dict; the engine's state is untouched.
Update the existing dict in place.
Why: state = {...} points the local name at a new dict; the engine's state is untouched. The door sees no key - the same rebind trap as with numbers and lists.
Trap
Replacing state with a new dict inside a room.
def room_cave(state):
state = {"has_key": True}
return "door"This rebinds the local name only
Why: state = {...} points the local name at a new dict; the engine's state is untouched. The door sees no key - the same rebind trap as with numbers and lists.
| engine's state | after |
|---|---|
| has_key | still False |
Update the existing dict in place.
def room_cave(state):
state["has_key"] = True
return "door"Change a key, do not replace the dict
Why: state["has_key"] = True edits the shared dict, so the change is seen everywhere. Mutate the state; never reassign it.
| engine's state | after |
|---|---|
| has_key | True |
Two truths and a lie
Sort into buckets
Some of these hold up and some are the exact mistakes this lesson is built to prevent. Sort them.
Concept
Create the state dict once, above the engine loop. Making it inside the loop would reset the player's progress every pass.
Concept
Double-check there is a real path to "win" and to "lose". An unreachable win means the player can never finish; an unreachable lose removes the stakes.
Section
Part 9
Concept
Instead of a flag per item, keep a list of what the player carries: state = {"items": []}, then state["items"].append("key").
Check for an item with in: "key" in state["items"]. This scales to any number of items.
Explain it to yourself
Discussion prompt
In Collecting several items this move is made:
Check with in and and
Why is that legal? Name the rule or definition it rests on before you read on.
Hint: If you can only say "because that is what you do", the rule is the thing to go and find.
Answer:
Verified by execution: ['key', 'map'], then True - the player has both.
Worked example
state = {"items": []}
state["items"].append("key")
state["items"].append("map")
print(state["items"])
print("key" in state["items"] and "map" in state["items"])append adds each item to the shared list
Why: The list grows in place, so every room sees the collected items.
Check with in and and
Why: Verified by execution: ['key', 'map'], then True - the player has both.
| step | items |
|---|---|
| append key | ['key'] |
| append map | ['key', 'map'] |
Concept
Gate a path behind more than one item with and: the player needs the key and the map to proceed.
This lets you build longer quests where earlier rooms set up later ones.
Fill the middle
Fill in the blanks
From A door needing key and map — one line has had its right-hand side removed. Put it back.
def final_door(state):
have = state["items"]
if "key" in have and "map" in have:
return "win"
print("You are missing something.")
return "lose"
print(final_door(___))
Why: have is what everything below it consumes, so the wrong expression here fails later and somewhere else. The and-condition passes only when the player has collected both.
Worked example
def final_door(state):
have = state["items"]
if "key" in have and "map" in have:
return "win"
print("You are missing something.")
return "lose"
print(final_door({"items": ["key", "map"]}))Both items required
Why: The and-condition passes only when the player has collected both.
Read the outcome
Why: Verified by execution: with both items, returns "win"; missing either, "lose".
| items | returns |
|---|---|
| key + map | win |
| key only | lose |
Blank canvas
Draw it
Draw what A door needing key and map just did — the shape of it, not the line-by-line working. One picture, labels only where you need them. Then check it against the steps: anything you could not draw is a step you followed rather than understood.
Concept
Track a "health" number in state; some rooms subtract from it. The game ends in a loss if health drops to 0 or below.
Worked example
state = {"health": 100}
state["health"] -= 30
print(state["health"])
print(state["health"] > 0)Damage lowers health in place
Why: -= 30 subtracts from the running health, like a reverse accumulator.
A check tells if the player survives
Why: Verified by execution: 70, then True (still alive).
| step | health | alive? |
|---|---|---|
| start | 100 | True |
| -30 | 70 | True |
Error analysis
Annotate
Walk the callouts on Taking damage. Each one is a place this is easy to get subtly wrong.
Intuition
Items, health, score, doors opened - every changing fact about the game lives in one state dict. The rooms just read and update that world.
Once you see the game as 'state plus rooms that change it', adding features is just adding keys and a few checks.
Explain it
Discussion prompt
Explain State is the whole world to a student a year behind you. No notation, no jargon they have not met — and it still has to be true.
Hint: If your explanation needs a symbol they have never seen, you are describing the notation rather than the idea.
Answer:
Items, health, score, doors opened - every changing fact about the game lives in one state dict. The rooms just read and update that world.
Concept
Wrap the whole game in an outer while that asks 'play again?' and restarts with fresh state if the player says yes.
Analogy
Discussion prompt
Explain A play-again loop by analogy to something with no Python Fundamentals in it at all — a queue, a recipe, a map, a bank balance, whatever fits. Then say where your analogy breaks.
Hint: An analogy that never breaks is not an analogy, it is the same idea wearing a hat. Find the seam — that is the part that is actually new.
Answer:
Wrap the whole game in an outer while that asks 'play again?' and restarts with fresh state if the player says yes.
Fill the middle
Fill in the blanks
From Playing more than once — one line has had its right-hand side removed. Put it back.
again = "y"
rounds = 0
while again == "y":
rounds += 1
print("playing round", rounds)
again = input("Again? (y/n) ").strip().lower()
print("rounds:", rounds)
Why: again is what everything below it consumes, so the wrong expression here fails later and somewhere else. Each round would set up fresh state and run the engine; here we just count rounds.
Worked example
The player types y, then n:
again = "y"
rounds = 0
while again == "y":
rounds += 1
print("playing round", rounds)
again = input("Again? (y/n) ").strip().lower()
print("rounds:", rounds)The outer loop restarts the game
Why: Each round would set up fresh state and run the engine; here we just count rounds.
Read the output
Why: Verified by execution: two rounds, then rounds: 2.
| again | rounds |
|---|---|
| y | 1 |
| y then n | 2 -> stop |
Trade off
Comparison matrix
From Playing more than once: every row here is a choice with a cost. Fill the rounds column, then say which row you would actually pick and what you give up for it.
| again | rounds |
|---|---|
| y | 1 |
| y then n | 2 -> stop |
Concept
Ideas to grow the game: more rooms and items, an enemy that costs health, a shop that spends score, a locked ending that needs a full inventory.
Every one of these is just more of the same toolkit - functions, loops, conditionals, dicts, strings. You already have everything you need.
Counterexample
Discussion prompt
Ideas to grow the game: more rooms and items, an enemy that costs health, a shop that spends score, a locked ending that needs a full inventory.
That is stated as though it always holds. Do one of two things: produce a case where it fails, or say precisely what rules such a case out. "It just does" is not on the menu.
Hint: Hunt at the extremes first — zero, one, negative, empty, equal. If every extreme survives, the reason they survive is the proof.
Answer:
Every one of these is just more of the same toolkit - functions, loops, conditionals, dicts, strings. You already have everything you need.
Section
Part 10
Pattern
1. Make one state dict before the loop
Why: state = {"has_key": False, "score": 0} - items and numbers in one place.
2. Pass state into rooms that use it
Why: def room_x(state): so every room shares the same object.
3. Update in place: state[key] = ...
Why: Mutate the dict; never reassign it, or the change is lost.
4. Read state to branch and to end
Why: if state["has_key"]: unlocks a path; the final state chooses win vs lose.
Pattern
At least 4 rooms, a way to win, a way to lose
Why: That is the capstone bar - a complete, playable game.
Every room returns a handled name; every choice re-prompts if invalid
Why: No dead ends, no None rooms, no crashes on odd input.
State initialized once and passed everywhere it is needed
Why: So progress and the ending actually depend on what the player did.
Real world
Discussion prompt
Outside this lesson: where does Session 14 - Capstone: Adventure Game (Part 2) actually turn up? Name one concrete situation — a job, a piece of software someone ships, a decision somebody has to make — and say which part of Finishing checklist is doing the work in it.
Hint: Vague is the failure mode here. "Engineering" is not a situation; "deciding whether this build is fast enough to ship" is.
Answer:
Session 14 of the Python Fundamentals series - the capstone, part 2. Finish the adventure game with state that travels through the play-through: a dictionary holding items and a score, rooms that read and update it, a locked door that checks for a key, and real win/lose endings chosen from the final state.
Check
Trace has_key.
def cave(state):
state["has_key"] = True
s = {"has_key": False}
cave(s)
print(s["has_key"])| step | s['has_key'] |
|---|---|
| after cave(s) | ? |
Check your understanding
What does this print?
Answer: A
Why: cave updates the shared dict in place with state["has_key"] = True, so s reflects the change: True. Verified by execution.
Check
This room reassigns state.
def cave(state):
state = {"has_key": True}
s = {"has_key": False}
cave(s)
print(s["has_key"])| step | s['has_key'] |
|---|---|
| after cave(s) | ? |
Check your understanding
What does this print?
Answer: A
Why: state = {...} rebinds only the local name to a new dict; the caller's s is unchanged, so it stays False. Update in place (state["has_key"] = True) to make it stick. Verified by execution.
Check
The player skipped the key.
def door(state):
if state["has_key"]:
return "win"
return "lose"
print(door({"has_key": False}))| has_key | returns |
|---|---|
| False | ? |
Check your understanding
What does this print?
Answer: A
Why: has_key is False, so the if is skipped and the function returns "lose". The earlier choice (skipping the key) determined this ending. Verified by execution.
Check
Trace the score.
state = {"score": 0}
state["score"] += 20
state["score"] += 5
print(state["score"])| step | score |
|---|---|
| +20 | ? |
| +5 | ? |
Check your understanding
What does this print?
Answer: A
Why: The score accumulates: 0 + 20 = 20, then + 5 = 25. Verified by execution.
Comparison
Comparison matrix
From Check: the score: refill the score column from what you know. The rest of the table is as it appeared.
| step | score |
|---|---|
| +20 | ? |
| +5 | ? |
Check
Where should state be created?
# A: state = {...} above the while loop
# B: state = {...} as the first line inside the while loop| inside loop | effect |
|---|---|
| resets each pass | ? |
Check your understanding
Where should the state dict be created?
Answer: A
Why: State must be created once, above the loop, so it carries across rooms. Creating it inside the loop would reset the player's items and score every pass. Verified by reasoning about the loop.
Connect it up
Draw it
One page, no notation unless you need it: draw how these connect — Tracking State · Inventory with a Dict · Decisions from State · A Score · Win and Lose Endings · The Full Game. Put an arrow wherever one of them is what makes another possible, and label the arrow with why.
Recap
You finished the capstone: state in a dict travels with the player, rooms read and update it, and the final state chooses a real win or lose ending.
| Phase 1 skill | Used for |
|---|---|
| functions | one per room, plus helpers |
| while loop | the game engine |
| if / elif | choices and endings |
| dict | the player's state |
| strings | cleaning input |
| errors | handling odd input |
Initialize state once, mutate it in place, keep every ending reachable - and every room forgiving. This game is yours to keep, extend, and show off. Well done finishing Phase 1.
Want this taught 1-on-1? Alexander tutors Python Fundamentals — $55/session, free consultation.