Skip to content
wannacode

Screens that return an answer

In short: A screen that returns an answer closes with pop(result) and hands the result to the screen below (onResult), while changes to the stack are written down during the turn and applied at its end with apply.

Problem

The screen asks. Who hears the answer?

In the last lesson screens only opened and closed. Now a screen has to ask something and get an answer. The fight has two enemies, the player presses "attack", and the fight needs to know which one. The menu has to ask "Are you sure?" before quitting the game.

The first attempt is a shared variable: the target screen writes the choice into selectedTarget, and the fight reads it. The player picks the Goblin, then opens the choice again and changes their mind (x):

enemies = ["Wolf", "Goblin"]
selected_target = -1
def target_screen(command):
global selected_target
if command != "x":
selected_target = int(command) - 1
def fight_turn():
if selected_target >= 0:
print("You hit " + enemies[selected_target] + ".")
else:
print("Nobody is hit.")
# The player picks 2, then changes their mind (x).
target_screen("2")
fight_turn()
target_screen("x")
fight_turn()
Output
You hit Goblin.
You hit Goblin.

The second hit lands on the Goblin, but the player cancelled. The choice from the last turn is still in the variable. Who should set it back to -1? The fight, after reading it? And what if the inventory uses the same variable too? A shared variable has no owner, so old answers stay in it.

The second attempt is to let the target call the fight itself:

class TargetScreen(Screen):
def handle(self, command):
# The target knows the fight and calls it itself.
self.fight.attack(int(command) - 1)
self.stack.pop()

That works, but now the target knows the fight. In the game, "who?" is asked by an attack, by a spell and by a potion that heals an ally. Each of them wants its own target screen, or one screen with an if for everyone who uses it. You need a way for the answer to go back to whoever asked, without the screen that answers knowing who that is.

The idea

pop(result) and onResult

The screen that asks opens the one that answers with push. The one that answers removes itself with pop and hands over the answer: pop(result). The stack knows who is below: exactly the screen that asked. So it passes the answer on by calling its on_result.

The screen gets one more method. By default it does nothing: most screens never ask anything.

class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# The answer of the screen that was above this one.
def on_result(self, result):
pass

And pop from the last lesson becomes pop(result):

def pop(self, result):
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(result)

This way the two sides do not know each other. The target knows the question and the shape of the answer: the number of an enemy, or None for "cancel". The fight knows what to do with the number. It is like calling a method that returns a value, except that turns pass between the question and the answer: the player thinks, presses a key, may change their mind.

See it

Picking a target

The target draws a list of the living enemies and waits for a digit. x is "back" and returns None. The number is counted from 0, because it is an index into the list:

class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Target")
self.stack = stack
self.enemies = enemies
def draw(self):
line = "Who do you hit?"
for i in range(len(self.enemies)):
line += f" {i + 1}) {self.enemies[i].name}"
print(line + " x) back")
def handle(self, command):
if command == "x":
self.stack.pop(None)
return
choice = int(command)
if 1 <= choice <= len(self.enemies):
self.stack.pop(choice - 1)

The fight opens the target on 1 and reads the answer in on_result. When there are no enemies left, the fight also returns an answer to the screen below it, the map: "victory".

class FightScreen(Screen):
def __init__(self, stack):
super().__init__("Fight")
self.stack = stack
self.enemies = [Enemy("Wolf", 4), Enemy("Goblin", 7)]
def draw(self):
line = "Fight:"
for enemy in self.enemies:
line += f" {enemy.name} {enemy.hp}"
print(line + " | 1) attack")
def handle(self, command):
if command == "1":
self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result):
if result is None:
print("You change your mind. Nobody is hit.")
return
enemy = self.enemies[result]
enemy.hp -= 4
if enemy.hp > 0:
print("You hit " + enemy.name + " for 4.")
return
print(enemy.name + " falls.")
self.enemies.remove(enemy)
if not self.enemies:
self.stack.pop("victory")

The program reads its commands from the input. Each turn draws only the screen on top, to keep the output short:

stack = ScreenStack()
stack.push(MapScreen(stack))
while stack.top() is not None:
stack.top().draw()
command = input().strip()
print("> " + command)
stack.top().handle(command)
print("Goodbye.")
Output
Map: potions 2 | f) fight d) drop a potion q) quit
> f
Fight: Wolf 4 Goblin 7 | 1) attack
> 1
Who do you hit? 1) Wolf 2) Goblin x) back
> 2
You hit Goblin for 4.
Fight: Wolf 4 Goblin 3 | 1) attack
> 1
Who do you hit? 1) Wolf 2) Goblin x) back
> x
You change your mind. Nobody is hit.
Fight: Wolf 4 Goblin 3 | 1) attack
> 1
Who do you hit? 1) Wolf 2) Goblin x) back
> 2
Goblin falls.
Fight: Wolf 4 | 1) attack
> 1
Who do you hit? 1) Wolf x) back
> 1
Wolf falls.
Victory! Back to the map.
Map: potions 2 | f) fight d) drop a potion q) quit
> d
Are you sure you want to drop a potion? 1) yes 2) no
> 2
The potion stays.
Map: potions 2 | f) fight d) drop a potion q) quit
> q
Are you sure you want to quit? 1) yes 2) no
> 1
Goodbye.

The input is:

Input
f
1
2
1
x
1
2
1
1
d
2
q
1

Cancelling (x) hits nobody: the answer is empty and the fight knows it. The choice from the previous turn is not left anywhere.

More precisely, if you are curious

The whole program, with the map and the "Are you sure?" question from the next card:

class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# The answer of the screen that was above this one.
def on_result(self, result):
pass
class ScreenStack:
def __init__(self):
self.screens = []
def push(self, screen):
self.screens.append(screen)
def pop(self, result):
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(result)
def top(self):
return self.screens[-1] if self.screens else None
def names(self):
return "[" + ", ".join(s.name for s in self.screens) + "]"
class Enemy:
def __init__(self, name, hp):
self.name = name
self.hp = hp
class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Target")
self.stack = stack
self.enemies = enemies
def draw(self):
line = "Who do you hit?"
for i in range(len(self.enemies)):
line += f" {i + 1}) {self.enemies[i].name}"
print(line + " x) back")
def handle(self, command):
if command == "x":
self.stack.pop(None)
return
choice = int(command)
if 1 <= choice <= len(self.enemies):
self.stack.pop(choice - 1)
class FightScreen(Screen):
def __init__(self, stack):
super().__init__("Fight")
self.stack = stack
self.enemies = [Enemy("Wolf", 4), Enemy("Goblin", 7)]
def draw(self):
line = "Fight:"
for enemy in self.enemies:
line += f" {enemy.name} {enemy.hp}"
print(line + " | 1) attack")
def handle(self, command):
if command == "1":
self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result):
if result is None:
print("You change your mind. Nobody is hit.")
return
enemy = self.enemies[result]
enemy.hp -= 4
if enemy.hp > 0:
print("You hit " + enemy.name + " for 4.")
return
print(enemy.name + " falls.")
self.enemies.remove(enemy)
if not self.enemies:
self.stack.pop("victory")
class ConfirmScreen(Screen):
def __init__(self, stack, question):
super().__init__(question)
self.stack = stack
self.question = question
def draw(self):
print(self.question + " 1) yes 2) no")
def handle(self, command):
if command == "1":
self.stack.pop(True)
elif command == "2":
self.stack.pop(False)
class MapScreen(Screen):
def __init__(self, stack):
super().__init__("Map")
self.stack = stack
self.potions = 2
self.pending = ""
def draw(self):
print("Map: potions " + str(self.potions) + " | f) fight d) drop a potion q) quit")
def handle(self, command):
if command == "f":
self.stack.push(FightScreen(self.stack))
elif command == "d":
self.pending = "drop"
self.stack.push(ConfirmScreen(self.stack, "Are you sure you want to drop a potion?"))
elif command == "q":
self.pending = "quit"
self.stack.push(ConfirmScreen(self.stack, "Are you sure you want to quit?"))
def on_result(self, result):
asked = self.pending
self.pending = ""
if asked == "drop":
if result is True:
self.potions -= 1
print("You drop a potion.")
else:
print("The potion stays.")
elif asked == "quit":
if result is True:
self.stack.pop(None)
elif result == "victory":
print("Victory! Back to the map.")
stack = ScreenStack()
stack.push(MapScreen(stack))
while stack.top() is not None:
stack.top().draw()
command = input().strip()
print("> " + command)
stack.top().handle(command)
print("Goodbye.")

The idea

One question for everything: "Are you sure?"

The question screen does not know what it is asking. It shows the text it was given and returns True or False:

class ConfirmScreen(Screen):
def __init__(self, stack, question):
super().__init__(question)
self.stack = stack
self.question = question
def draw(self):
print(self.question + " 1) yes 2) no")
def handle(self, command):
if command == "1":
self.stack.pop(True)
elif command == "2":
self.stack.pop(False)

The map uses it for two different questions: whether to drop a potion and whether to quit. But the answer is only "yes" or "no". How does the map know which question it answers? It remembers what it asked. Before the push it writes pending, and when the answer comes, it reads it and clears it right away:

class MapScreen(Screen):
def __init__(self, stack):
super().__init__("Map")
self.stack = stack
self.potions = 2
self.pending = ""
def draw(self):
print("Map: potions " + str(self.potions) + " | f) fight d) drop a potion q) quit")
def handle(self, command):
if command == "f":
self.stack.push(FightScreen(self.stack))
elif command == "d":
self.pending = "drop"
self.stack.push(ConfirmScreen(self.stack, "Are you sure you want to drop a potion?"))
elif command == "q":
self.pending = "quit"
self.stack.push(ConfirmScreen(self.stack, "Are you sure you want to quit?"))
def on_result(self, result):
asked = self.pending
self.pending = ""
if asked == "drop":
if result is True:
self.potions -= 1
print("You drop a potion.")
else:
print("The potion stays.")
elif asked == "quit":
if result is True:
self.stack.pop(None)
elif result == "victory":
print("Victory! Back to the map.")

The clearing matters. If pending stayed "drop", the next answer from the fight ("victory") would be read as the answer to the potion question. That is the same bug as the shared variable from the start, but now inside one screen that owns its own question. The real game does the same: the fight remembers whether it asked for an attack, a skill or a potion.

Problem

The stack changes while the turn is still running

So far the loop called only handle on the top. A real turn also has update: the screen updates its state after the command. Here is the same fight, but every screen writes into a log what it does. In update, the target counts the enemies in its list:

class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Target")
self.stack = stack
self.enemies = enemies
def handle(self, command):
index = int(command) - 1
print("Target takes " + command + " and returns " + str(index))
self.stack.pop(index)
def update(self):
print("Target updates, enemies in the list: " + str(len(self.enemies)))

The loop is like in the real game: take the top, give it the command, update it.

top = stack.top()
top.handle(command)
top.update()
class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# The answer of the screen that was above this one.
def on_result(self, result):
pass
class ScreenStack:
def __init__(self):
self.screens = []
def push(self, screen):
self.screens.append(screen)
def pop(self, result):
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(result)
def top(self):
return self.screens[-1] if self.screens else None
def names(self):
return "[" + ", ".join(s.name for s in self.screens) + "]"
class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Target")
self.stack = stack
self.enemies = enemies
def handle(self, command):
index = int(command) - 1
print("Target takes " + command + " and returns " + str(index))
self.stack.pop(index)
def update(self):
print("Target updates, enemies in the list: " + str(len(self.enemies)))
class FightScreen(Screen):
def __init__(self, stack):
super().__init__("Fight")
self.stack = stack
self.enemies = ["Wolf", "Goblin"]
def handle(self, command):
print("Fight takes " + command + " and opens Target")
self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result):
enemy = self.enemies.pop(result)
print("Fight gets " + str(result) + ": " + enemy + " falls")
if not self.enemies:
print("Fight: no enemies left, returns victory")
self.stack.pop("victory")
class MapScreen(Screen):
def __init__(self, stack):
super().__init__("Map")
self.stack = stack
def on_result(self, result):
print("Map gets " + str(result) + ": opens Level up")
self.stack.push(Screen("Level up"))
stack = ScreenStack()
stack.push(MapScreen(stack))
stack.push(FightScreen(stack))
for command in ["1", "2", "1", "1"]:
print("> " + command)
top = stack.top()
top.handle(command)
top.update()
print(stack.names())
Output
> 1
Fight takes 1 and opens Target
> 2
Target takes 2 and returns 1
Fight gets 1: Goblin falls
Target updates, enemies in the list: 1
> 1
Fight takes 1 and opens Target
> 1
Target takes 1 and returns 0
Fight gets 0: Wolf falls
Fight: no enemies left, returns victory
Map gets victory: opens Level up
Target updates, enemies in the list: 0
[Map, Level up]

Look at the last turn. The target has not finished its handle yet, and inside it pop has already removed it, the fight has hit the Wolf, the fight has returned "victory", and the map has opened "Level up". Only then does the loop call update on the target: a screen that is no longer on the stack updates a list of 0 enemies for a fight that is over. In the real game, such an update would read an enemy by number from an empty list, and the game would stop with an error.

If the loop asked for the top again before update, it would update "Level up": the wrong screen, one that has not been drawn even once. There are two roots. The stack changes in the middle of the turn. And the answers run nested: the fight's onResult runs inside the target's handle, and the map's onResult inside that.

More precisely, if you are curious

It gets worse when you walk all screens on the stack (say, each has an update) and one of them removes itself during the walk. Then the languages behave differently:

screens = ["Map", "Message", "Fight", "Target"]
for screen in screens:
print("update " + screen)
if screen == "Message":
screens.remove(screen)
Output
update Map
update Message
update Target
Python says nothing. for goes by index: Message is removed, everything shifts one place to the left, and Fight, which was next, is skipped. The program goes on with a wrong result.

An error that stops the program is at least visible. A silent bug, where one screen is not updated on one turn, is much harder to catch.

The idea

Deferred changes: write now, apply later

The fix is to not touch the stack during the turn. push and pop no longer move anything. They only write down the change into a list, changes. At the end of the turn, apply walks the list and applies the changes one by one. A change is a small object: a kind, a screen (for push) and an answer (for pop).

class Change:
def __init__(self, kind, screen, result):
self.kind = kind
self.screen = screen
self.result = result
class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
self.changes.clear()

The loop calls apply last:

top = stack.top()
top.handle(command)
top.update()
stack.apply()

The same fight with the same screens, only the stack is different:

class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# The answer of the screen that was above this one.
def on_result(self, result):
pass
class Change:
def __init__(self, kind, screen, result):
self.kind = kind
self.screen = screen
self.result = result
class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
self.changes.clear()
def names(self):
return "[" + ", ".join(s.name for s in self.screens) + "]"
class TargetScreen(Screen):
def __init__(self, stack, enemies):
super().__init__("Target")
self.stack = stack
self.enemies = enemies
def handle(self, command):
index = int(command) - 1
print("Target takes " + command + " and returns " + str(index))
self.stack.pop(index)
def update(self):
print("Target updates, enemies in the list: " + str(len(self.enemies)))
class FightScreen(Screen):
def __init__(self, stack):
super().__init__("Fight")
self.stack = stack
self.enemies = ["Wolf", "Goblin"]
def handle(self, command):
print("Fight takes " + command + " and opens Target")
self.stack.push(TargetScreen(self.stack, self.enemies))
def on_result(self, result):
enemy = self.enemies.pop(result)
print("Fight gets " + str(result) + ": " + enemy + " falls")
if not self.enemies:
print("Fight: no enemies left, returns victory")
self.stack.pop("victory")
class MapScreen(Screen):
def __init__(self, stack):
super().__init__("Map")
self.stack = stack
def on_result(self, result):
print("Map gets " + str(result) + ": opens Level up")
self.stack.push(Screen("Level up"))
stack = ScreenStack()
stack.push(MapScreen(stack))
stack.push(FightScreen(stack))
stack.apply()
for command in ["1", "2", "1", "1"]:
print("> " + command)
top = stack.top()
top.handle(command)
top.update()
stack.apply()
print(stack.names())
Output
> 1
Fight takes 1 and opens Target
> 2
Target takes 2 and returns 1
Target updates, enemies in the list: 2
Fight gets 1: Goblin falls
> 1
Fight takes 1 and opens Target
> 1
Target takes 1 and returns 0
Target updates, enemies in the list: 1
Fight gets 0: Wolf falls
Fight: no enemies left, returns victory
Map gets victory: opens Level up
[Map, Level up]

Now the target finishes handle and update while it is still on top and everything around it is as it was before the turn. Only then does apply remove it, and the fight gets its answer. Every screen works in a stack that does not change under its feet.

Three details in apply matter:

  • while with an index, not foreach. While apply is applying a pop, the fight gets its answer and may write a new change: pop(victory). It goes to the end of the same list, and while reads its length again and reaches it too. foreach would happen to work here too, but while with an index says "read the length again" explicitly and does not depend on how the iterator behaves.
  • The list is cleared at the end. Otherwise, on the next turn, the same changes would be applied again.
  • The order is the order of writing. If a screen writes a replace and then a push in one turn, the stack does exactly that.

See it

One turn, change by change

class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
self.changes.clear()
# One turn.
top = stack.top()
top.handle(command)
top.update()
stack.apply()
Step by step, in words
  1. The last turn of the fight. On the stack: Map, Fight, and Target on top. Only the Wolf is left. The list of changes is empty.
  2. Command 1 goes to the top, Target. It calls pop(0): 0 is the number of the chosen enemy. The stack does not move. The change is only written into changes.
  3. update runs on Target, which is still on the stack. Everything it sees is as it was before the turn: the fight is not over yet.
  4. End of the turn: apply takes change 0. Target leaves the stack, and 0 goes down to Fight through on_result. The Wolf falls, no enemies are left, and Fight calls pop(victory): a new change at the end of the list.
  5. while reads the length of changes again, so it reaches the new change too. Fight leaves, and Map gets "victory". It writes push(Level up).
  6. The third change is a push: Level up goes on the stack, above Map.
  7. The list is done. changes is cleared, so the same changes are not applied again next turn. The stack is Map, Level up. Every screen got its answer after the turn, never in the middle of one.

Try it

Fill in the blank

This is the loop of apply. What must the condition be, so that it also reaches the changes written while it is running?

def apply(self):
    index = 0
    while index < :
        change = self.changes[index]
        index += 1
        if change.kind == "push":
            self.screens.append(change.screen)
        elif change.kind == "pop":
            self.screens.pop()
            below = self.top()
            if below is not None:
                below.on_result(change.result)
    self.changes.clear()

Try it

Predict the output

NamedScreen is a screen that only says what it got. The stack is the one with deferred changes. What does the program print?

stack = ScreenStack()
stack.push(NamedScreen("Map"))
stack.push(NamedScreen("Fight"))
stack.apply()
stack.pop("victory")
stack.push(NamedScreen("Level up"))
print("On top: " + stack.top().name)
stack.apply()
print("On top: " + stack.top().name)
Answer

The idea

Transitions: replace and reset

Not every change of screen is a question and an answer. Some are transitions with no way back. For those the stack has two more changes:

def replace(self, screen):
self.changes.append(Change("replace", screen, None))
def reset(self, screen):
self.changes.append(Change("reset", screen, None))

apply has one branch for each of them:

elif change.kind == "replace":
self.screens.pop()
self.screens.append(change.screen)
elif change.kind == "reset":
self.screens.clear()
if change.screen is not None:
self.screens.append(change.screen)
  • replace removes the top screen and puts another one in its place. The title screen opens hero creation, and hero creation opens the village. With push, the title screen would stay at the bottom, and on "back" the player would land in it in the middle of the game.
  • reset clears the whole stack and puts one screen (or none: then the game ends). There are two uses for it: the hero has fallen (the game over screen) and a save has been loaded (the village or the map).

Why not use pop for the end of the game? The stack is Village, Map, Fight. Here are both ways:

stack = ScreenStack()
stack.push(NamedScreen("Title"))
stack.apply()
print(stack.names())
stack.replace(NamedScreen("New hero"))
stack.apply()
print(stack.names())
stack.replace(NamedScreen("Village"))
stack.apply()
print(stack.names())
print("The hero falls. With pop:")
popped = in_fight()
popped.pop("defeat")
popped.pop("defeat")
popped.push(NamedScreen("Game over"))
popped.apply()
print(popped.names())
print("The hero falls. With reset:")
clean = in_fight()
clean.reset(NamedScreen("Game over"))
clean.apply()
print(clean.names())
Output
[Title]
[New hero]
[Village]
The hero falls. With pop:
Map gets defeat
Village gets defeat
[Village, Game over]
The hero falls. With reset:
[Game over]

With pop, the map and the village get "defeat" and react as if the fight had ended the normal way. You also have to know how many screens are below. And the village stays under the game over screen. reset calls onResult on nobody: this is not an answer, it is a fresh start.

More precisely, if you are curious

replace and reset are deferred too: they are written down and applied in apply, in the order they were written. Hero creation in the game calls replace(the village) and right after it push(the message with the start of the story). After apply the stack is the village with the message on top, not the other way around.

The whole program:

class Screen:
def __init__(self, name):
self.name = name
def draw(self):
pass
def handle(self, command):
pass
def update(self):
pass
# The answer of the screen that was above this one.
def on_result(self, result):
pass
class Change:
def __init__(self, kind, screen, result):
self.kind = kind
self.screen = screen
self.result = result
class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def replace(self, screen):
self.changes.append(Change("replace", screen, None))
def reset(self, screen):
self.changes.append(Change("reset", screen, None))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
elif change.kind == "replace":
self.screens.pop()
self.screens.append(change.screen)
elif change.kind == "reset":
self.screens.clear()
if change.screen is not None:
self.screens.append(change.screen)
self.changes.clear()
def names(self):
return "[" + ", ".join(s.name for s in self.screens) + "]"
class NamedScreen(Screen):
def on_result(self, result):
print(self.name + " gets " + str(result))
def in_fight():
stack = ScreenStack()
stack.push(NamedScreen("Village"))
stack.push(NamedScreen("Map"))
stack.push(NamedScreen("Fight"))
stack.apply()
return stack
stack = ScreenStack()
stack.push(NamedScreen("Title"))
stack.apply()
print(stack.names())
stack.replace(NamedScreen("New hero"))
stack.apply()
print(stack.names())
stack.replace(NamedScreen("Village"))
stack.apply()
print(stack.names())
print("The hero falls. With pop:")
popped = in_fight()
popped.pop("defeat")
popped.pop("defeat")
popped.push(NamedScreen("Game over"))
popped.apply()
print(popped.names())
print("The hero falls. With reset:")
clean = in_fight()
clean.reset(NamedScreen("Game over"))
clean.apply()
print(clean.names())

The idea

In the real game

The stack of Chronicles of Arcanum is the same, with three more things:

# The stack does not change during handling: push, pop and replace only record the change, and
# apply() applies it after the turn. Otherwise a screen being removed from the stack would keep running.
class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(SimpleNamespace(kind='push', screen=screen, result=None))
def pop(self, result):
self.changes.append(SimpleNamespace(kind='pop', screen=None, result=result))
def replace(self, screen):
self.changes.append(SimpleNamespace(kind='replace', screen=screen, result=None))
# Clears the whole stack (loading, game over); with None the stack stays empty.
def reset(self, screen):
self.changes.append(SimpleNamespace(kind='reset', screen=screen, result=None))
def top(self):
return None if len(self.screens) == 0 else self.screens[-1]
def is_empty(self):
return len(self.screens) == 0 and len(self.changes) == 0
def contains(self, kind_name):
for screen in self.screens:
if getattr(screen, 'kind_name', None) == kind_name:
return True
return False
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == 'push':
self.screens.append(change.screen)
elif change.kind == 'pop':
# pop() on an empty list in Python is an error; in JavaScript it returns undefined.
if len(self.screens) > 0:
self.screens.pop()
below = self.top()
if below is not None:
below.on_result(change.result)
elif change.kind == 'replace':
if len(self.screens) > 0:
self.screens.pop()
self.screens.append(change.screen)
else:
self.screens = []
if change.screen is not None:
self.screens.append(change.screen)
self.changes = []
# Drawing starts from the topmost opaque screen and goes up.
def draw(self, buffer):
first = len(self.screens) - 1
while first > 0 and self.screens[first].is_transparent():
first -= 1
for i in range(first, len(self.screens)):
if i >= 0:
self.screens[i].draw(buffer)
  • isEmpty also looks at the changes. The game starts with a push of the title screen, and quitting is a reset with no screen. The loop stops when there are neither screens nor waiting changes.
  • contains asks whether the stack already has a screen of a given kind. That way the level up screen does not go in twice if two levels are gained in one turn.
  • draw draws bottom up from the topmost opaque screen, as in the last lesson.

Picking a target in the game is a transparent screen over the fight. It does not hit anyone itself: it returns the number with pop, and the fight decides what to do. Next to it are the general pick from a list (a skill, a potion) and the pick of a save slot, which returns the name of the slot, not a number:

# Choosing a target is a transparent screen over the battle. It does not attack: pop(result) returns the target number,
# and the battle decides what to do with it.
class TargetScreen(Screen):
def __init__(self, game, battle):
super().__init__(game)
self.battle = battle
self.note = ''
def is_transparent(self):
return True
def draw(self, buffer):
enemies = self.battle.alive_enemies()
buffer.box(24, 9, 36, len(enemies) + 4, t('target.title'))
for i in range(len(enemies)):
buffer.text(26, 10 + i, str(i + 1) + ') ' + pad(enemies[i].name, 22) + ' ' + str(enemies[i].hp) + '/' + str(enemies[i].max_hp()), enemies[i].kind.color)
buffer.text(26, 11 + len(enemies), self.note if len(self.note) > 0 else t('target.help'), 'gray')
def handle(self, command):
if command == 'x':
self.game.stack.pop(None)
return
choice = parse_int_strict(command)
if choice is None or choice < 1 or choice > len(self.battle.alive_enemies()):
self.note = t('ui.unknown', {'command': command})
return
self.game.stack.pop(choice - 1)
# A general choice from a list (skill, potion, slot): returns a 0-based number or None.
class ChoiceScreen(Screen):
def __init__(self, game, title, labels):
super().__init__(game)
self.title = title
self.labels = labels
self.note = ''
def is_transparent(self):
return True
def draw(self, buffer):
buffer.box(4, 3, 52, len(self.labels) + 4, self.title)
for i in range(len(self.labels)):
buffer.text(6, 4 + i, str(i + 1) + ') ' + self.labels[i], 'default')
buffer.text(6, 5 + len(self.labels), self.note if len(self.note) > 0 else t('choice.help'), 'gray')
def handle(self, command):
if command == 'x':
self.game.stack.pop(None)
return
choice = parse_int_strict(command)
if choice is None or choice < 1 or choice > len(self.labels):
self.note = t('ui.unknown', {'command': command})
return
self.game.stack.pop(choice - 1)
class SlotScreen(ChoiceScreen):
def __init__(self, game, mode):
labels = []
for slot in SAVE_SLOTS:
labels.append(t('slot.label', {'slot': slot, 'summary': slot_summary(game.io, slot)}))
super().__init__(game, t('slot.saveTitle' if mode == 'save' else 'slot.loadTitle'), labels)
def handle(self, command):
choice = parse_int_strict(command)
if choice is not None and choice >= 1 and choice <= len(SAVE_SLOTS):
self.game.stack.pop(SAVE_SLOTS[choice - 1])
return
super().handle(command)

The fight remembers what it asked (pending) and reads the answer in on_result:

def on_result(self, result):
battle = self.battle
hero = battle.hero
pending = self.pending
self.pending = None
if result is None or pending is None:
return
if pending.kind == 'attack':
battle.hero_act(AttackAction(hero, battle.alive_enemies()[result]))
elif pending.kind == 'skill':
skill = SKILLS[hero.skills[result]]
if hero.mana < skill.mana:
self.note = t('battle.noMana', {'skill': nm(skill)})
elif hero.cooldown_of(skill.id) > 0:
self.note = t('battle.cooldown', {'skill': nm(skill), 'turns': hero.cooldown_of(skill.id)})
elif skill.target == 'one' and len(battle.alive_enemies()) > 1:
self.pending = SimpleNamespace(kind='skillTarget', skill=skill)
self.game.stack.push(TargetScreen(self.game, battle))
else:
battle.hero_act(SkillAction(hero, skill, battle.alive_enemies()[0]))
elif pending.kind == 'skillTarget':
battle.hero_act(SkillAction(hero, pending.skill, battle.alive_enemies()[result]))
else:
battle.hero_act(ItemAction(hero, self.potion_ids()[result]))

Look at the skill branch. The player picked a skill that needs a target, and there is more than one enemy. Then the fight, inside onResult, writes a new question: a push of the target screen. This happens during apply, and while reaches it and applies it in the same turn. The player sees the list of enemies right after the list of skills.

The menu is the other screen that asks. "Are you sure?" is the same general screen, and the answer "yes" to the quit question is a reset with no screen: the stack becomes empty and the game ends.

class ConfirmScreen(Screen):
def __init__(self, game, question):
super().__init__(game)
self.question = question
def is_transparent(self):
return True
def draw(self, buffer):
buffer.box(16, 6, 28, 5, '')
buffer.text(18, 7, self.question, 'white')
buffer.text(18, 8, '1) ' + t('ui.yes') + ' 2) ' + t('ui.no'), 'default')
def handle(self, command):
if command == '1':
self.game.stack.pop(True)
elif command == '2' or command == 'x':
self.game.stack.pop(False)
def on_result(self, result):
game = self.game
pending = self.pending
self.pending = ''
if result is None:
return
if pending == 'save':
game.io.save(result, save_game(game))
self.note = t('menu.saved', {'slot': result})
elif pending == 'load':
error = load_game(game, game.io.load(result))
if error is not None:
self.note = error
else:
enter_world(game, t('load.done', {'slot': result}))
elif pending == 'quit' and result is True:
game.stack.reset(None)

Try it

Find the bug

A colleague swapped two lines in apply. The fight program from "Deferred changes" now prints:

class ScreenStack:
def __init__(self):
self.screens = []
self.changes = []
def push(self, screen):
self.changes.append(Change("push", screen, None))
def pop(self, result):
self.changes.append(Change("pop", None, result))
def top(self):
return self.screens[-1] if self.screens else None
def apply(self):
index = 0
while index < len(self.changes):
change = self.changes[index]
index += 1
if change.kind == "push":
self.screens.append(change.screen)
elif change.kind == "pop":
below = self.top()
self.screens.pop()
if below is not None:
below.on_result(change.result)
self.changes.clear()
Output
> 1
Fight takes 1 and opens Target
> 2
Target takes 2 and returns 1
Target updates, enemies in the list: 2
> 1
Fight takes 1 and opens Target
> 1
Target takes 1 and returns 0
Target updates, enemies in the list: 2
[Map, Fight]

The target returns a number, but the fight never finds out: both enemies are alive, and at the end the stack is the map and the fight. Which line runs too early?

Pick the line with the bug.

More precisely

More precisely, for the curious

Why not a callback? The fight could give the target a function: "when the player picks, call this".

# The fight tells the target what to do with the choice.
self.stack.push(TargetScreen(self.enemies, lambda index: self.attack(index)))

That works, and it is common. The cost is in the details. The function is called by the target, inside its handle: we are in the middle of the turn again, unless the call is deferred too. The function holds on to the fight, and the game has to be saved: a stack of screens with data can be saved, but a function cannot be written to a file. And the order becomes invisible: who will be called is clear only once you see who passed what. onResult says the same with a rule you can see in the stack: the answer always goes to the screen below.

The same thing elsewhere. On Android, one activity opens another and waits for a result (startActivityForResult, today the Activity Result API). Modal dialogs in desktop apps return a value when they close ("OK" or "Cancel"). Navigation in mobile apps is a stack too: you open a screen on top, and "back" returns you, sometimes with a result.

With await. In JavaScript, C# and Python you can write const target = await targetScreen.open(): the fight "waits" for the answer as if it were an ordinary call. It looks more natural, but in a turn-based game with one loop it is harder. The fight is no longer an object that receives commands, it is a function paused in the middle. Its state is hidden in "where it stopped", and that does not go into a save file. And it still has to be clear at which moment of the turn it resumes.

The type of the answer. Here the answer is any value, and the fight knows it is a number. It can be stricter: the target returns its own type (say, a record with the number), or the screen is generic over the type of the answer. In the game the answers are few and simple (a number, "yes" or "no", a slot name, the result of a fight), so the check of pending is enough.

Exercise

A shop with two questions

Solve it in your editor. Start from the stack with deferred changes from this lesson and from ConfirmScreen.

Shop. ShopScreen has 20 gold and sells a potion for 5 and a sword for 12. Choosing an item opens QuantityScreen, which returns the count (from 1 to 9), or nothing if the player presses x. Then the shop opens ConfirmScreen with the question "Buy 2 x Potion for 10 gold?". One screen collects two answers, one after the other: on_result knows which answer has arrived by pending, which moves from "count" to "confirm". If there is not enough gold, nothing is bought. x in the shop closes it.

Note: ConfirmScreen is opened from on_result, that is, while apply is still running. That is why the loop in apply reads the length of the list again.

Input
1
2
1
2
1
2
2
x
2
1
1
1
1
1
x
Output
Shop: gold 20 | 1) Potion 5 2) Sword 12 x) leave
> 1
How many? 1-9 | x) back
> 2
Buy 2 x Potion for 10 gold? 1) yes 2) no
> 1
You buy 2 x Potion.
Shop: gold 10 | 1) Potion 5 2) Sword 12 x) leave
> 2
How many? 1-9 | x) back
> 1
Buy 1 x Sword for 12 gold? 1) yes 2) no
> 2
You do not buy.
Shop: gold 10 | 1) Potion 5 2) Sword 12 x) leave
> 2
How many? 1-9 | x) back
> x
You change your mind.
Shop: gold 10 | 1) Potion 5 2) Sword 12 x) leave
> 2
How many? 1-9 | x) back
> 1
Buy 1 x Sword for 12 gold? 1) yes 2) no
> 1
Not enough gold.
Shop: gold 10 | 1) Potion 5 2) Sword 12 x) leave
> 1
How many? 1-9 | x) back
> 1
Buy 1 x Potion for 5 gold? 1) yes 2) no
> 1
You buy 1 x Potion.
Shop: gold 5 | 1) Potion 5 2) Sword 12 x) leave
> x
Goodbye.

More tasks

  • Selling. Add s) sell: the shop asks which item (one more screen with an answer) and how many, and pays back half the price, rounded down. How many values will pending have now?
  • Bag. Keep the bought items in a list and print it on leaving. A potion bought twice is one line with 3 pieces, not two lines.
  • No pending. Make QuantityScreen and ConfirmScreen return a record with the name of the question and the value. Can the shop then do without pending? What do you gain and what do you lose?

Test

Check yourself

1. Why does the target screen not call the fight itself, and instead return a number with pop?
2. The map opened "Are you sure?" and got true. How does it know whether to quit the game or to drop a potion?
3. Why does apply walk the changes with while (index < length) and not with foreach?
4. A colleague deleted the last line of apply, the one that clears changes. What happens?
5. The hero falls in a fight. The stack is Village, Map, Fight. Why does the game call reset(Game over) and not two pops and a push?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·