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):

const enemies = ["Wolf", "Goblin"];
let selectedTarget = -1;
function targetScreen(command) {
if (command !== "x") selectedTarget = Number(command) - 1;
}
function fightTurn() {
if (selectedTarget >= 0) console.log("You hit " + enemies[selectedTarget] + ".");
else console.log("Nobody is hit.");
}
// The player picks 2, then changes their mind (x).
targetScreen("2");
fightTurn();
targetScreen("x");
fightTurn();
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 extends Screen {
handle(command) {
// The target knows the fight and calls it itself.
this.fight.attack(Number(command) - 1);
this.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 onResult.

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

class Screen {
constructor(name) {
this.name = name;
}
draw() {}
handle(command) {}
update() {}
// The answer of the screen that was above this one.
onResult(result) {}
}

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

pop(result) {
this.screens.pop();
const below = this.top();
if (below !== null) below.onResult(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 null 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 null. The number is counted from 0, because it is an index into the list:

class TargetScreen extends Screen {
constructor(stack, enemies) {
super("Target");
this.stack = stack;
this.enemies = enemies;
}
draw() {
let line = "Who do you hit?";
for (let i = 0; i < this.enemies.length; i++) {
line += ` ${i + 1}) ${this.enemies[i].name}`;
}
console.log(line + " x) back");
}
handle(command) {
if (command === "x") {
this.stack.pop(null);
return;
}
const choice = Number(command);
if (choice >= 1 && choice <= this.enemies.length) this.stack.pop(choice - 1);
}
}

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

class FightScreen extends Screen {
constructor(stack) {
super("Fight");
this.stack = stack;
this.enemies = [new Enemy("Wolf", 4), new Enemy("Goblin", 7)];
}
draw() {
let line = "Fight:";
for (const enemy of this.enemies) line += ` ${enemy.name} ${enemy.hp}`;
console.log(line + " | 1) attack");
}
handle(command) {
if (command === "1") this.stack.push(new TargetScreen(this.stack, this.enemies));
}
onResult(result) {
if (result === null) {
console.log("You change your mind. Nobody is hit.");
return;
}
const enemy = this.enemies[result];
enemy.hp -= 4;
if (enemy.hp > 0) {
console.log("You hit " + enemy.name + " for 4.");
return;
}
console.log(enemy.name + " falls.");
this.enemies.splice(this.enemies.indexOf(enemy), 1);
if (this.enemies.length === 0) this.stack.pop("victory");
}
}

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

const rl = createInterface({ input: process.stdin });
const lines = rl[Symbol.asyncIterator]();
const stack = new ScreenStack();
stack.push(new MapScreen(stack));
while (stack.top() !== null) {
stack.top().draw();
const command = (await lines.next()).value.trim();
console.log("> " + command);
stack.top().handle(command);
}
console.log("Goodbye.");
rl.close();
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:

import { createInterface } from "node:readline";
class Screen {
constructor(name) {
this.name = name;
}
draw() {}
handle(command) {}
update() {}
// The answer of the screen that was above this one.
onResult(result) {}
}
class ScreenStack {
constructor() {
this.screens = [];
}
push(screen) {
this.screens.push(screen);
}
pop(result) {
this.screens.pop();
const below = this.top();
if (below !== null) below.onResult(result);
}
top() {
return this.screens.length === 0 ? null : this.screens[this.screens.length - 1];
}
names() {
return "[" + this.screens.map((s) => s.name).join(", ") + "]";
}
}
class Enemy {
constructor(name, hp) {
this.name = name;
this.hp = hp;
}
}
class TargetScreen extends Screen {
constructor(stack, enemies) {
super("Target");
this.stack = stack;
this.enemies = enemies;
}
draw() {
let line = "Who do you hit?";
for (let i = 0; i < this.enemies.length; i++) {
line += ` ${i + 1}) ${this.enemies[i].name}`;
}
console.log(line + " x) back");
}
handle(command) {
if (command === "x") {
this.stack.pop(null);
return;
}
const choice = Number(command);
if (choice >= 1 && choice <= this.enemies.length) this.stack.pop(choice - 1);
}
}
class FightScreen extends Screen {
constructor(stack) {
super("Fight");
this.stack = stack;
this.enemies = [new Enemy("Wolf", 4), new Enemy("Goblin", 7)];
}
draw() {
let line = "Fight:";
for (const enemy of this.enemies) line += ` ${enemy.name} ${enemy.hp}`;
console.log(line + " | 1) attack");
}
handle(command) {
if (command === "1") this.stack.push(new TargetScreen(this.stack, this.enemies));
}
onResult(result) {
if (result === null) {
console.log("You change your mind. Nobody is hit.");
return;
}
const enemy = this.enemies[result];
enemy.hp -= 4;
if (enemy.hp > 0) {
console.log("You hit " + enemy.name + " for 4.");
return;
}
console.log(enemy.name + " falls.");
this.enemies.splice(this.enemies.indexOf(enemy), 1);
if (this.enemies.length === 0) this.stack.pop("victory");
}
}
class ConfirmScreen extends Screen {
constructor(stack, question) {
super(question);
this.stack = stack;
this.question = question;
}
draw() {
console.log(this.question + " 1) yes 2) no");
}
handle(command) {
if (command === "1") this.stack.pop(true);
else if (command === "2") this.stack.pop(false);
}
}
class MapScreen extends Screen {
constructor(stack) {
super("Map");
this.stack = stack;
this.potions = 2;
this.pending = "";
}
draw() {
console.log("Map: potions " + this.potions + " | f) fight d) drop a potion q) quit");
}
handle(command) {
if (command === "f") {
this.stack.push(new FightScreen(this.stack));
} else if (command === "d") {
this.pending = "drop";
this.stack.push(new ConfirmScreen(this.stack, "Are you sure you want to drop a potion?"));
} else if (command === "q") {
this.pending = "quit";
this.stack.push(new ConfirmScreen(this.stack, "Are you sure you want to quit?"));
}
}
onResult(result) {
const asked = this.pending;
this.pending = "";
if (asked === "drop") {
if (result === true) {
this.potions--;
console.log("You drop a potion.");
} else {
console.log("The potion stays.");
}
} else if (asked === "quit") {
if (result === true) this.stack.pop(null);
} else if (result === "victory") {
console.log("Victory! Back to the map.");
}
}
}
const rl = createInterface({ input: process.stdin });
const lines = rl[Symbol.asyncIterator]();
const stack = new ScreenStack();
stack.push(new MapScreen(stack));
while (stack.top() !== null) {
stack.top().draw();
const command = (await lines.next()).value.trim();
console.log("> " + command);
stack.top().handle(command);
}
console.log("Goodbye.");
rl.close();

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 extends Screen {
constructor(stack, question) {
super(question);
this.stack = stack;
this.question = question;
}
draw() {
console.log(this.question + " 1) yes 2) no");
}
handle(command) {
if (command === "1") this.stack.pop(true);
else if (command === "2") this.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 extends Screen {
constructor(stack) {
super("Map");
this.stack = stack;
this.potions = 2;
this.pending = "";
}
draw() {
console.log("Map: potions " + this.potions + " | f) fight d) drop a potion q) quit");
}
handle(command) {
if (command === "f") {
this.stack.push(new FightScreen(this.stack));
} else if (command === "d") {
this.pending = "drop";
this.stack.push(new ConfirmScreen(this.stack, "Are you sure you want to drop a potion?"));
} else if (command === "q") {
this.pending = "quit";
this.stack.push(new ConfirmScreen(this.stack, "Are you sure you want to quit?"));
}
}
onResult(result) {
const asked = this.pending;
this.pending = "";
if (asked === "drop") {
if (result === true) {
this.potions--;
console.log("You drop a potion.");
} else {
console.log("The potion stays.");
}
} else if (asked === "quit") {
if (result === true) this.stack.pop(null);
} else if (result === "victory") {
console.log("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 extends Screen {
constructor(stack, enemies) {
super("Target");
this.stack = stack;
this.enemies = enemies;
}
handle(command) {
const index = Number(command) - 1;
console.log("Target takes " + command + " and returns " + index);
this.stack.pop(index);
}
update() {
console.log("Target updates, enemies in the list: " + this.enemies.length);
}
}

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

const top = stack.top();
top.handle(command);
top.update();
class Screen {
constructor(name) {
this.name = name;
}
draw() {}
handle(command) {}
update() {}
// The answer of the screen that was above this one.
onResult(result) {}
}
class ScreenStack {
constructor() {
this.screens = [];
}
push(screen) {
this.screens.push(screen);
}
pop(result) {
this.screens.pop();
const below = this.top();
if (below !== null) below.onResult(result);
}
top() {
return this.screens.length === 0 ? null : this.screens[this.screens.length - 1];
}
names() {
return "[" + this.screens.map((s) => s.name).join(", ") + "]";
}
}
class TargetScreen extends Screen {
constructor(stack, enemies) {
super("Target");
this.stack = stack;
this.enemies = enemies;
}
handle(command) {
const index = Number(command) - 1;
console.log("Target takes " + command + " and returns " + index);
this.stack.pop(index);
}
update() {
console.log("Target updates, enemies in the list: " + this.enemies.length);
}
}
class FightScreen extends Screen {
constructor(stack) {
super("Fight");
this.stack = stack;
this.enemies = ["Wolf", "Goblin"];
}
handle(command) {
console.log("Fight takes " + command + " and opens Target");
this.stack.push(new TargetScreen(this.stack, this.enemies));
}
onResult(result) {
const [enemy] = this.enemies.splice(result, 1);
console.log("Fight gets " + result + ": " + enemy + " falls");
if (this.enemies.length === 0) {
console.log("Fight: no enemies left, returns victory");
this.stack.pop("victory");
}
}
}
class MapScreen extends Screen {
constructor(stack) {
super("Map");
this.stack = stack;
}
onResult(result) {
console.log("Map gets " + result + ": opens Level up");
this.stack.push(new Screen("Level up"));
}
}
const stack = new ScreenStack();
stack.push(new MapScreen(stack));
stack.push(new FightScreen(stack));
for (const command of ["1", "2", "1", "1"]) {
console.log("> " + command);
const top = stack.top();
top.handle(command);
top.update();
}
console.log(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:

const screens = ["Map", "Message", "Fight", "Target"];
for (const screen of screens) {
console.log("update " + screen);
if (screen === "Message") screens.splice(screens.indexOf(screen), 1);
}
Output
update Map
update Message
update Target
JavaScript says nothing. for...of over an array 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 {
constructor(kind, screen, result) {
this.kind = kind;
this.screen = screen;
this.result = result;
}
}
class ScreenStack {
constructor() {
this.screens = [];
this.changes = [];
}
push(screen) {
this.changes.push(new Change("push", screen, null));
}
pop(result) {
this.changes.push(new Change("pop", null, result));
}
top() {
return this.screens.length === 0 ? null : this.screens[this.screens.length - 1];
}
apply() {
let index = 0;
while (index < this.changes.length) {
const change = this.changes[index];
index++;
if (change.kind === "push") {
this.screens.push(change.screen);
} else if (change.kind === "pop") {
this.screens.pop();
const below = this.top();
if (below !== null) below.onResult(change.result);
}
}
this.changes = [];
}
}

The loop calls apply last:

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

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

class Screen {
constructor(name) {
this.name = name;
}
draw() {}
handle(command) {}
update() {}
// The answer of the screen that was above this one.
onResult(result) {}
}
class Change {
constructor(kind, screen, result) {
this.kind = kind;
this.screen = screen;
this.result = result;
}
}
class ScreenStack {
constructor() {
this.screens = [];
this.changes = [];
}
push(screen) {
this.changes.push(new Change("push", screen, null));
}
pop(result) {
this.changes.push(new Change("pop", null, result));
}
top() {
return this.screens.length === 0 ? null : this.screens[this.screens.length - 1];
}
apply() {
let index = 0;
while (index < this.changes.length) {
const change = this.changes[index];
index++;
if (change.kind === "push") {
this.screens.push(change.screen);
} else if (change.kind === "pop") {
this.screens.pop();
const below = this.top();
if (below !== null) below.onResult(change.result);
}
}
this.changes = [];
}
names() {
return "[" + this.screens.map((s) => s.name).join(", ") + "]";
}
}
class TargetScreen extends Screen {
constructor(stack, enemies) {
super("Target");
this.stack = stack;
this.enemies = enemies;
}
handle(command) {
const index = Number(command) - 1;
console.log("Target takes " + command + " and returns " + index);
this.stack.pop(index);
}
update() {
console.log("Target updates, enemies in the list: " + this.enemies.length);
}
}
class FightScreen extends Screen {
constructor(stack) {
super("Fight");
this.stack = stack;
this.enemies = ["Wolf", "Goblin"];
}
handle(command) {
console.log("Fight takes " + command + " and opens Target");
this.stack.push(new TargetScreen(this.stack, this.enemies));
}
onResult(result) {
const [enemy] = this.enemies.splice(result, 1);
console.log("Fight gets " + result + ": " + enemy + " falls");
if (this.enemies.length === 0) {
console.log("Fight: no enemies left, returns victory");
this.stack.pop("victory");
}
}
}
class MapScreen extends Screen {
constructor(stack) {
super("Map");
this.stack = stack;
}
onResult(result) {
console.log("Map gets " + result + ": opens Level up");
this.stack.push(new Screen("Level up"));
}
}
const stack = new ScreenStack();
stack.push(new MapScreen(stack));
stack.push(new FightScreen(stack));
stack.apply();
for (const command of ["1", "2", "1", "1"]) {
console.log("> " + command);
const top = stack.top();
top.handle(command);
top.update();
stack.apply();
}
console.log(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 {
constructor() {
this.screens = [];
this.changes = [];
}
push(screen) {
this.changes.push(new Change("push", screen, null));
}
pop(result) {
this.changes.push(new Change("pop", null, result));
}
top() {
return this.screens.length === 0 ? null : this.screens[this.screens.length - 1];
}
apply() {
let index = 0;
while (index < this.changes.length) {
const change = this.changes[index];
index++;
if (change.kind === "push") {
this.screens.push(change.screen);
} else if (change.kind === "pop") {
this.screens.pop();
const below = this.top();
if (below !== null) below.onResult(change.result);
}
}
this.changes = [];
}
}
// One turn.
const 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 onResult. 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?

apply() {
  let index = 0;
  while (index < ) {
    const change = this.changes[index];
    index++;
    if (change.kind === "push") {
      this.screens.push(change.screen);
    } else if (change.kind === "pop") {
      this.screens.pop();
      const below = this.top();
      if (below !== null) below.onResult(change.result);
    }
  }
  this.changes = [];
}

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?

const stack = new ScreenStack();
stack.push(new NamedScreen("Map"));
stack.push(new NamedScreen("Fight"));
stack.apply();
stack.pop("victory");
stack.push(new NamedScreen("Level up"));
console.log("On top: " + stack.top().name);
stack.apply();
console.log("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:

replace(screen) {
this.changes.push(new Change("replace", screen, null));
}
reset(screen) {
this.changes.push(new Change("reset", screen, null));
}

apply has one branch for each of them:

} else if (change.kind === "replace") {
this.screens.pop();
this.screens.push(change.screen);
} else if (change.kind === "reset") {
this.screens = [];
if (change.screen !== null) this.screens.push(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:

const stack = new ScreenStack();
stack.push(new NamedScreen("Title"));
stack.apply();
console.log(stack.names());
stack.replace(new NamedScreen("New hero"));
stack.apply();
console.log(stack.names());
stack.replace(new NamedScreen("Village"));
stack.apply();
console.log(stack.names());
console.log("The hero falls. With pop:");
const popped = inFight();
popped.pop("defeat");
popped.pop("defeat");
popped.push(new NamedScreen("Game over"));
popped.apply();
console.log(popped.names());
console.log("The hero falls. With reset:");
const clean = inFight();
clean.reset(new NamedScreen("Game over"));
clean.apply();
console.log(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 {
constructor(name) {
this.name = name;
}
draw() {}
handle(command) {}
update() {}
// The answer of the screen that was above this one.
onResult(result) {}
}
class Change {
constructor(kind, screen, result) {
this.kind = kind;
this.screen = screen;
this.result = result;
}
}
class ScreenStack {
constructor() {
this.screens = [];
this.changes = [];
}
push(screen) {
this.changes.push(new Change("push", screen, null));
}
pop(result) {
this.changes.push(new Change("pop", null, result));
}
replace(screen) {
this.changes.push(new Change("replace", screen, null));
}
reset(screen) {
this.changes.push(new Change("reset", screen, null));
}
top() {
return this.screens.length === 0 ? null : this.screens[this.screens.length - 1];
}
apply() {
let index = 0;
while (index < this.changes.length) {
const change = this.changes[index];
index++;
if (change.kind === "push") {
this.screens.push(change.screen);
} else if (change.kind === "pop") {
this.screens.pop();
const below = this.top();
if (below !== null) below.onResult(change.result);
} else if (change.kind === "replace") {
this.screens.pop();
this.screens.push(change.screen);
} else if (change.kind === "reset") {
this.screens = [];
if (change.screen !== null) this.screens.push(change.screen);
}
}
this.changes = [];
}
names() {
return "[" + this.screens.map((s) => s.name).join(", ") + "]";
}
}
class NamedScreen extends Screen {
onResult(result) {
console.log(this.name + " gets " + result);
}
}
function inFight() {
const stack = new ScreenStack();
stack.push(new NamedScreen("Village"));
stack.push(new NamedScreen("Map"));
stack.push(new NamedScreen("Fight"));
stack.apply();
return stack;
}
const stack = new ScreenStack();
stack.push(new NamedScreen("Title"));
stack.apply();
console.log(stack.names());
stack.replace(new NamedScreen("New hero"));
stack.apply();
console.log(stack.names());
stack.replace(new NamedScreen("Village"));
stack.apply();
console.log(stack.names());
console.log("The hero falls. With pop:");
const popped = inFight();
popped.pop("defeat");
popped.pop("defeat");
popped.push(new NamedScreen("Game over"));
popped.apply();
console.log(popped.names());
console.log("The hero falls. With reset:");
const clean = inFight();
clean.reset(new NamedScreen("Game over"));
clean.apply();
console.log(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 {
constructor() {
this.screens = [];
this.changes = [];
}
push(screen) {
this.changes.push({ kind: 'push', screen: screen, result: null });
}
pop(result) {
this.changes.push({ kind: 'pop', screen: null, result: result });
}
replace(screen) {
this.changes.push({ kind: 'replace', screen: screen, result: null });
}
// Clears the whole stack (loading, game over); with null the stack stays empty.
reset(screen) {
this.changes.push({ kind: 'reset', screen: screen, result: null });
}
top() {
return this.screens.length === 0 ? null : this.screens[this.screens.length - 1];
}
isEmpty() {
return this.screens.length === 0 && this.changes.length === 0;
}
contains(kindName) {
for (const screen of this.screens) {
if (screen.kindName === kindName) return true;
}
return false;
}
apply() {
let index = 0;
while (index < this.changes.length) {
const change = this.changes[index];
index++;
if (change.kind === 'push') {
this.screens.push(change.screen);
} else if (change.kind === 'pop') {
this.screens.pop();
const below = this.top();
if (below !== null) below.onResult(change.result);
} else if (change.kind === 'replace') {
this.screens.pop();
this.screens.push(change.screen);
} else {
this.screens = [];
if (change.screen !== null) this.screens.push(change.screen);
}
}
this.changes = [];
}
// Drawing starts from the topmost opaque screen and goes up.
draw(buffer) {
let first = this.screens.length - 1;
while (first > 0 && this.screens[first].isTransparent()) first--;
for (let i = first; i < this.screens.length; i++) {
if (i >= 0) this.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 extends Screen {
constructor(game, battle) {
super(game);
this.battle = battle;
this.note = '';
}
isTransparent() {
return true;
}
draw(buffer) {
const enemies = this.battle.aliveEnemies();
buffer.box(24, 9, 36, enemies.length + 4, t('target.title'));
for (let i = 0; i < enemies.length; i++) {
buffer.text(26, 10 + i, (i + 1) + ') ' + pad(enemies[i].name, 22) + ' ' + enemies[i].hp + '/' + enemies[i].maxHp(), enemies[i].kind.color);
}
buffer.text(26, 11 + enemies.length, this.note.length > 0 ? this.note : t('target.help'), 'gray');
}
handle(command) {
if (command === 'x') {
this.game.stack.pop(null);
return;
}
const choice = parseIntStrict(command);
if (choice === null || choice < 1 || choice > this.battle.aliveEnemies().length) {
this.note = t('ui.unknown', { command: command });
return;
}
this.game.stack.pop(choice - 1);
}
}
// A general choice from a list (skill, potion, slot): returns a 0-based number or null.
class ChoiceScreen extends Screen {
constructor(game, title, labels) {
super(game);
this.title = title;
this.labels = labels;
this.note = '';
}
isTransparent() {
return true;
}
draw(buffer) {
buffer.box(4, 3, 52, this.labels.length + 4, this.title);
for (let i = 0; i < this.labels.length; i++) buffer.text(6, 4 + i, (i + 1) + ') ' + this.labels[i], 'default');
buffer.text(6, 5 + this.labels.length, this.note.length > 0 ? this.note : t('choice.help'), 'gray');
}
handle(command) {
if (command === 'x') {
this.game.stack.pop(null);
return;
}
const choice = parseIntStrict(command);
if (choice === null || choice < 1 || choice > this.labels.length) {
this.note = t('ui.unknown', { command: command });
return;
}
this.game.stack.pop(choice - 1);
}
}
class SlotScreen extends ChoiceScreen {
constructor(game, mode) {
const labels = [];
for (const slot of SAVE_SLOTS) labels.push(t('slot.label', { slot: slot, summary: slotSummary(game.io, slot) }));
super(game, t(mode === 'save' ? 'slot.saveTitle' : 'slot.loadTitle'), labels);
}
handle(command) {
const choice = parseIntStrict(command);
if (choice !== null && choice >= 1 && choice <= SAVE_SLOTS.length) {
this.game.stack.pop(SAVE_SLOTS[choice - 1]);
return;
}
super.handle(command);
}
}

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

onResult(result) {
const battle = this.battle;
const hero = battle.hero;
const pending = this.pending;
this.pending = null;
if (result === null || pending === null) return;
if (pending.kind === 'attack') {
battle.heroAct(new AttackAction(hero, battle.aliveEnemies()[result]));
} else if (pending.kind === 'skill') {
const skill = SKILLS.get(hero.skills[result]);
if (hero.mana < skill.mana) {
this.note = t('battle.noMana', { skill: nm(skill) });
} else if (hero.cooldownOf(skill.id) > 0) {
this.note = t('battle.cooldown', { skill: nm(skill), turns: hero.cooldownOf(skill.id) });
} else if (skill.target === 'one' && battle.aliveEnemies().length > 1) {
this.pending = { kind: 'skillTarget', skill: skill };
this.game.stack.push(new TargetScreen(this.game, battle));
} else {
battle.heroAct(new SkillAction(hero, skill, battle.aliveEnemies()[0]));
}
} else if (pending.kind === 'skillTarget') {
battle.heroAct(new SkillAction(hero, pending.skill, battle.aliveEnemies()[result]));
} else {
battle.heroAct(new ItemAction(hero, this.potionIds()[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 extends Screen {
constructor(game, question) {
super(game);
this.question = question;
}
isTransparent() {
return true;
}
draw(buffer) {
buffer.box(16, 6, 28, 5, '');
buffer.text(18, 7, this.question, 'white');
buffer.text(18, 8, '1) ' + t('ui.yes') + ' 2) ' + t('ui.no'), 'default');
}
handle(command) {
if (command === '1') this.game.stack.pop(true);
else if (command === '2' || command === 'x') this.game.stack.pop(false);
}
}
onResult(result) {
const game = this.game;
const pending = this.pending;
this.pending = '';
if (result === null) return;
if (pending === 'save') {
game.io.save(result, saveGame(game));
this.note = t('menu.saved', { slot: result });
} else if (pending === 'load') {
const error = loadGame(game, game.io.load(result));
if (error !== null) this.note = error;
else enterWorld(game, t('load.done', { slot: result }));
} else if (pending === 'quit' && result === true) {
game.stack.reset(null);
}
}

Try it

Find the bug

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

class ScreenStack {
constructor() {
this.screens = [];
this.changes = [];
}
push(screen) {
this.changes.push(new Change("push", screen, null));
}
pop(result) {
this.changes.push(new Change("pop", null, result));
}
top() {
return this.screens.length === 0 ? null : this.screens[this.screens.length - 1];
}
apply() {
let index = 0;
while (index < this.changes.length) {
const change = this.changes[index];
index++;
if (change.kind === "push") {
this.screens.push(change.screen);
} else if (change.kind === "pop") {
const below = this.top();
this.screens.pop();
if (below !== null) below.onResult(change.result);
}
}
this.changes = [];
}
}
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.
this.stack.push(new TargetScreen(this.enemies, (index) => this.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: onResult 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 onResult, 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 ·