A stack of screens
In short: A stack of screens keeps the open screens on top of each other: input goes to the top one, push opens a new one, pop closes it, replace swaps it, and transparent ones are drawn over those below.
Problem
One variable for all screens
The game has a title screen, a village, the map, a battle, an inventory that opens over the map, and a menu that opens over the battle. Each screen reads commands its own way: on the map a is a step to the left, in the battle it is a hit, in the inventory it is nothing.
The first attempt almost everyone makes: one variable with the name of the screen, and an if on it. Once when handling the command and once more when drawing:
import { createInterface } from "node:readline";
const rl = createInterface({ input: process.stdin });let screen = "title";let wolf = 2;for await (const line of rl) { const command = line.trim(); if (screen === "title") { if (command === "n") screen = "map"; } else if (screen === "map") { if (command === "i") screen = "inventory"; if (command === "f") screen = "battle"; } else if (screen === "battle") { if (command === "i") screen = "inventory"; if (command === "a") { wolf--; if (wolf === 0) screen = "map"; } } else if (screen === "inventory") { if (command === "x") screen = "map"; } process.stdout.write(command + " → "); if (screen === "title") console.log("Chronicles of Arcanum"); else if (screen === "map") console.log(`Map, wolf: ${wolf}`); else if (screen === "battle") console.log(`Battle, wolf: ${wolf}`); else if (screen === "inventory") console.log("Inventory: Potion x2");}n → Map, wolf: 2i → Inventory: Potion x2x → Map, wolf: 2f → Battle, wolf: 2i → Inventory: Potion x2x → Map, wolf: 2a → Map, wolf: 2a → Map, wolf: 2The commands come one per line: n new game, i inventory, x close, f fight the wolf, a hit.
nixfixaaLook at the sixth line. The inventory was opened from the battle, and x closes it to the map. The battle is gone, the wolf is alive with 2 life, and the two hits after that land on the map, where a means nothing. The variable remembers where you are, but not where you came from.
The second problem shows up as the game grows. Every new screen is a new branch in every if: in the handling, in the drawing, and in every screen that opens it. A menu over the battle and over the map is two more branches in two places. Forget one, and you get a screen that takes commands but is never drawn.
The idea
A screen decides about itself
Turn the question around. Instead of the loop asking "which screen am I on" and knowing every screen, every screen is an object with the same four methods:
handle: what the command means here;update: what happens every turn while the screen is on top (in the battle, the wolf bites);draw: what the screen draws;isTransparent: whether the screen below it shows through.
// JavaScript has no abstract classes: here handle and draw do nothing,// and every screen writes its own.class Screen { constructor(game, name) { this.game = game; this.name = name; }
isTransparent() { return false; }
handle(command) {}
update() {}
draw(buffer) {}}handle and draw. Every screen writes its own. If you forget, the screen simply does nothing, with no error.Here are the battle and the inventory. The battle knows only about itself: i opens the inventory on top, a hits, and when the wolf falls, the battle removes itself. The inventory does not know where it was opened from. All it says is "remove me":
class BattleScreen extends Screen { constructor(game) { super(game, "Battle"); }
handle(command) { if (command === "i") this.game.stack.push(new InventoryScreen(this.game)); if (command === "a") { this.game.wolf--; if (this.game.wolf === 0) this.game.stack.pop(); } }
// Every turn of the battle, the wolf bites. update() { if (this.game.wolf > 0) this.game.life--; }
draw(buffer) { buffer.box(0, 0, TextBuffer.W, TextBuffer.H); buffer.write(2, 1, `Wolf: ${this.game.wolf}`); buffer.write(2, 2, `Life: ${this.game.life}`); buffer.write(2, 3, "a: hit i: inventory"); }}class InventoryScreen extends Screen { constructor(game) { super(game, "Inventory"); }
handle(command) { if (command === "x") this.game.stack.pop(); }
draw(buffer) { buffer.box(12, 1, 11, 3); buffer.write(13, 2, "Potion x2"); }}The screens draw into a TextBuffer: five rows of 24 characters. write puts text at a given place, box draws a frame. The whole buffer is in the program below, and how drawing works in a real terminal is the lesson on drawing.
A new screen is a new class and one line in the screen that opens it. No if on screen names grows.
More precisely, if you are curious
This is how it looks in the game. The screen region of the canon:
// Each screen is an object with the same interface. The game does not ask "which screen am I on"; it sends the command to// the screen on top of the stack.class Screen { constructor(game) { this.game = game; }
// A transparent screen (menu, inventory, dialog) lets the screen below it show through. isTransparent() { return false; }
handle(command) {}
update() {}
draw(buffer) {}
// The screen below the popped one gets its answer (the chosen target, "yes" or "no", the chosen slot). onResult(result) {}}The onResult method is for screens that send back an answer (the chosen target, "yes" or "no"). It is the topic of the next lesson.
The idea
The stack: where you came from
Screens sit on top of each other: the inventory over the battle, the battle over the map. When you close the top one, you want to be exactly where you were. That is a stack: the last one in is the first one out. The stack of screens has three operations:
- push: open a new screen on top (the map opens the battle, the battle opens the inventory);
- pop: close the top one and return to the one below it;
- replace: swap the top one for a new one, with no way back (the title screen becomes the map, hero creation becomes the village).
class ScreenStack { constructor() { this.screens = []; }
push(screen) { this.screens.push(screen); }
pop() { this.screens.pop(); }
replace(screen) { this.pop(); this.push(screen); }
top() { return this.screens[this.screens.length - 1]; }
names() { return `[${this.screens.map((s) => s.name).join(", ")}]`; }
draw(buffer) { this.top().draw(buffer); }}The game loop gives the command only to the top. Not to the map, not to the battle under the inventory. Only the top knows what the command means right now. The same commands, now with the stack. show prints the stack and the frame after every command:
function show(game, buffer) { console.log(game.stack.names()); buffer.clear(); game.stack.draw(buffer); buffer.print();}
const game = new Game();const buffer = new TextBuffer();game.stack.push(new TitleScreen(game));show(game, buffer);const rl = createInterface({ input: process.stdin });for await (const line of rl) { const command = line.trim(); console.log(`> ${command}`); const top = game.stack.top(); top.handle(command); top.update(); show(game, buffer);}[Title]
Chronicles of Arcanum
n: new game
> n[Map]#########################..@......~~~..........##.......w.~~~..........######################### Life: 10> i[Map, Inventory]
+---------+ |Potion x2| +---------+
> x[Map]#########################..@......~~~..........##.......w.~~~..........######################### Life: 10> f[Map, Battle]+----------------------+| Wolf: 2 || Life: 10 || a: hit i: inventory |+----------------------+> i[Map, Battle, Inventory]
+---------+ |Potion x2| +---------+
> x[Map, Battle]+----------------------+| Wolf: 2 || Life: 9 || a: hit i: inventory |+----------------------+> a[Map, Battle]+----------------------+| Wolf: 1 || Life: 8 || a: hit i: inventory |+----------------------+> a[Map]#########################..@......~~~..........##.........~~~..........######################### Life: 8The inventory opened from the battle now closes back to the battle: [Map, Battle]. The wolf falls on the second hit, the battle removes itself, and below it is the map, now without the w. No screen remembers where it came from. The stack does.
But look at the frames with the inventory. The map has disappeared, and the frame hangs in empty space. The stack's draw draws only the top. That is next.
More precisely, if you are curious
Only the top is updated. The loop calls update on the top. While the inventory is open over the battle, the battle waits and the wolf does not bite: the game is paused under the inventory. That is a choice, not a law. In a real-time game the world under the menu can keep going, and then the stack also updates screens below the top that say they do not stop.
pop on an empty stack. If the last screen removes itself, the stack is empty and the next pop has nothing to remove. In JavaScript, pop() on an empty array returns undefined with no error, and the program fails later, at top().handle(...), far from the real cause. In the game an empty stack means the end: the loop runs until the stack is empty, and "Quit" on the title screen clears the stack.
Try it
Predict the output
The stack is empty. What is on it at the end?
const game = new Game();const stack = game.stack;stack.push(new TitleScreen(game));stack.replace(new MapScreen(game));stack.push(new BattleScreen(game));stack.push(new InventoryScreen(game));stack.pop();stack.pop();stack.push(new InventoryScreen(game));console.log(stack.names());The idea
Transparent screens
The inventory is a small frame over the map. For the map to show around it, the map has to be drawn first, and the inventory after it, on top of it. Like a painter who paints the background first, then the figures in front.
But not from the bottom screen. Under the battle is the map, and the battle covers all of it: drawing it is wasted work. So drawing starts at the topmost opaque screen. Go down from the top while the screens are transparent. The first one that is not transparent is the start. From there, draw upward:
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++) { this.screens[i].draw(buffer); }}The inventory says it is transparent:
class InventoryScreen extends Screen { constructor(game) { super(game, "Inventory"); }
isTransparent() { return true; }
handle(command) { if (command === "x") this.game.stack.pop(); }
draw(buffer) { buffer.box(12, 1, 11, 3); buffer.write(13, 2, "Potion x2"); }}Only these two changes. The same loop, the same commands:
[Title]
Chronicles of Arcanum
n: new game
> n[Map]#########################..@......~~~..........##.......w.~~~..........######################### Life: 10> i[Map, Inventory]#########################..@......~~+---------+##.......w.~~|Potion x2|#############+---------+# Life: 10> x[Map]#########################..@......~~~..........##.......w.~~~..........######################### Life: 10> f[Map, Battle]+----------------------+| Wolf: 2 || Life: 10 || a: hit i: inventory |+----------------------+> i[Map, Battle, Inventory]+----------------------+| Wolf: 2 +---------+|| Life: 9 |Potion x2||| a: hit i:+---------+|+----------------------+> x[Map, Battle]+----------------------+| Wolf: 2 || Life: 9 || a: hit i: inventory |+----------------------+> a[Map, Battle]+----------------------+| Wolf: 1 || Life: 8 || a: hit i: inventory |+----------------------+> a[Map]#########################..@......~~~..........##.........~~~..........######################### Life: 8[Map, Inventory]: the map, with the frame over it, and the wolf still shows underneath. [Map, Battle, Inventory]: drawing starts at the battle, because it is not transparent. The map under it is not drawn at all.
first > 0 stops at the bottom screen: it is always drawn, even if it is transparent, because there is nothing under it.
More precisely, if you are curious
The draw method from the screen-stack region of the canon:
// 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); }}The i >= 0 check is for an empty stack: then first is −1 and there is nothing to draw.
Why not simply from zero? If every opaque screen covers everything, the result is the same, but every frame draws all the screens below: map, battle, target choice, dialog. There is also something worse: an opaque screen that does not write every cell lets pieces of the map show through. "I am not transparent" is a promise that says "I cover everything", and the stack relies on it.
See it
Turn by turn
The same program with the transparent inventory. On the left is the stack: each plate is one screen with what it draws on its own. The command goes to the plate on top. The faded plates are under the topmost opaque one and are not drawn. Under the stack is the frame the program prints.
class ScreenStack { constructor() { this.screens = []; }
push(screen) { this.screens.push(screen); }
pop() { this.screens.pop(); }
replace(screen) { this.pop(); this.push(screen); }
top() { return this.screens[this.screens.length - 1]; }
names() { return `[${this.screens.map((s) => s.name).join(", ")}]`; }
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++) { this.screens[i].draw(buffer); } }}
for await (const line of rl) { const command = line.trim(); console.log(`> ${command}`); const top = game.stack.top(); top.handle(command); top.update(); show(game, buffer);}Step by step, in words
- The game starts with one screen on the stack: the title screen. It is the top, so it gets the input.
- n goes to the title screen. It puts the map in its own place (replace): the title screen is not coming back.
- i goes to the map, which puts the inventory on top (push). The inventory is transparent: the map is drawn first, then the inventory over it.
- x goes to the inventory: it removes itself (pop). Below it is the map, exactly as you left it.
- f: the map puts the battle on top (push). The battle is opaque, so the map under it is not drawn at all.
- i now goes to the battle, not to the map. The battle opens the inventory over itself. Drawing starts from the battle, the topmost opaque screen. The wolf bit: life 9.
- x closes the inventory, and below it is the battle: you return where you came from. The single variable from the start of the lesson took you to the map here.
- a goes to the battle: the wolf is hit, and the stack does not change. The battle's update runs: the wolf bites back, life 8.
- The wolf falls. The battle removes itself (pop), and the map is on top again, now without the wolf.
- Input goes to the top. Drawing goes up from the topmost opaque screen. Screens that send back an answer, and changes that wait for the end of the turn, are the next lesson.
Try it
Fill in the blank
The stack's drawing. While the screen at first is transparent, go down:
draw(buffer) {
let first = this.screens.length - 1;
while (first > 0 && ) first--;
for (let i = first; i < this.screens.length; i++) {
this.screens[i].draw(buffer);
}
}The question is whether the screen at position first is transparent: this.screens[first].isTransparent(). If it is, the one under it has to be drawn too.
Try it
Find the bug
A colleague rewrote the stack. The program with the commands n, i, x prints:
[Title]
Chronicles of Arcanum
n: new game
> n[Map]#########################..@......~~~..........##.......w.~~~..........######################### Life: 10> i[Map, Inventory]#########################..@......~~+---------+##.......w.~~|Potion x2|#############+---------+# Life: 10> x[Map, Inventory]#########################..@......~~+---------+##.......w.~~|Potion x2|#############+---------+# Life: 10The inventory does not close: after x the stack is still [Map, Inventory]. Which line has the bug?
Pick the line with the bug.
The top of the stack is the last element, not the first. With this.screens[0] the command goes to the map at the very bottom. The map does not know what x means, and the inventory never hears it. The list grows at the end, so push, pop and top all work with its end.
The idea
The stack in the real game
The screen-stack region of the canon. The methods are the same, with a few differences:
// 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); } }}- push, pop and replace do not touch the list right away. They record the change in
changes, andapply()carries them out after the turn. In our program the battle removes itself inhandle, and right after that the loop callsupdateon the same battle, which is no longer on the stack. It works here because a dead wolf does not bite. In a bigger game this is a bug that takes a long time to find. Why and how the changes are deferred is the next lesson. - pop carries an answer: the screen below gets it in
onResult. That is how the target choice screen returns the target to the battle. That is also in the next lesson. - reset clears the whole stack (loading a game, the end of the game), and contains asks whether a screen of a given kind is already on the stack (for example, so that a second level-up screen does not open).
More precisely
The same idea elsewhere
Navigation in mobile apps is a stack of screens. On Android and iOS you open a screen on top (push), and the Back button removes it (pop). Going in after login is usually a replace: Back should not lead to the login form again.
A modal dialog in the browser is a screen over the page. While it is open, only it gets clicks and keys, and the page under it is visible but does not react. The same as the inventory over the map.
The call stack is the same idea in the language itself. A method calls another method (push), that one returns (pop), and the caller continues exactly where it stopped. The battle continues after the inventory in the same way.
When a stack is not enough. A stack says there is exactly one screen on top and it gets everything. Sometimes that is not true:
- two screens side by side that work together: a map and a mini-map, a battle and the battle log, a two-player game on a split screen. Then the screen is a tree of panels, and input goes to the panel with focus;
- things that are always on top, whatever the screen: a life bar, a "Game saved" notice. They are a separate layer, drawn after the stack;
- windows that close in any order (a strategy game with five open windows). That is no longer a stack but a list with focus.
Exercise
A menu over everything
Solve this in your own editor. The whole program with transparent screens is below the task.
Menu. Add a MenuScreen named "Menu". m opens it from the map, from the battle and from the inventory. The menu is transparent, and x closes it. It draws a frame box(14, 2, 10, 3) with x: back inside it, at (15, 3).
nfimxxaa[Title]
Chronicles of Arcanum
n: new game
> n[Map]#########################..@......~~~..........##.......w.~~~..........######################### Life: 10> f[Map, Battle]+----------------------+| Wolf: 2 || Life: 10 || a: hit i: inventory |+----------------------+> i[Map, Battle, Inventory]+----------------------+| Wolf: 2 +---------+|| Life: 9 |Potion x2||| a: hit i:+---------+|+----------------------+> m[Map, Battle, Inventory, Menu]+----------------------+| Wolf: 2 +---------+|| Life: 9 |P+--------+| a: hit i:+-|x: back |+-------------+--------+> x[Map, Battle, Inventory]+----------------------+| Wolf: 2 +---------+|| Life: 9 |Potion x2||| a: hit i:+---------+|+----------------------+> x[Map, Battle]+----------------------+| Wolf: 2 || Life: 9 || a: hit i: inventory |+----------------------+> a[Map, Battle]+----------------------+| Wolf: 1 || Life: 8 || a: hit i: inventory |+----------------------+> a[Map]#########################..@......~~~..........##.........~~~..........######################### Life: 8Look at the frame with the menu: the battle, the inventory and the menu are drawn, in that order. The menu covers a corner of the inventory, because it is higher on the stack.
More precisely, if you are curious
The whole program with transparent screens:
import { createInterface } from "node:readline";
class TextBuffer { static W = 24; static H = 5;
constructor() { this.rows = []; }
clear() { this.rows = []; for (let y = 0; y < TextBuffer.H; y++) this.rows.push(" ".repeat(TextBuffer.W)); }
write(x, y, text) { const row = this.rows[y]; this.rows[y] = row.substring(0, x) + text + row.substring(x + text.length); }
box(x, y, w, h) { const edge = `+${"-".repeat(w - 2)}+`; this.write(x, y, edge); for (let i = 1; i < h - 1; i++) this.write(x, y + i, `|${" ".repeat(w - 2)}|`); this.write(x, y + h - 1, edge); }
print() { for (const row of this.rows) console.log(row.trimEnd()); }}
class Game { constructor() { this.stack = new ScreenStack(); this.life = 10; this.wolf = 2; }}
// JavaScript has no abstract classes: here handle and draw do nothing,// and every screen writes its own.class Screen { constructor(game, name) { this.game = game; this.name = name; }
isTransparent() { return false; }
handle(command) {}
update() {}
draw(buffer) {}}
class ScreenStack { constructor() { this.screens = []; }
push(screen) { this.screens.push(screen); }
pop() { this.screens.pop(); }
replace(screen) { this.pop(); this.push(screen); }
top() { return this.screens[this.screens.length - 1]; }
names() { return `[${this.screens.map((s) => s.name).join(", ")}]`; }
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++) { this.screens[i].draw(buffer); } }}
class TitleScreen extends Screen { constructor(game) { super(game, "Title"); }
handle(command) { if (command === "n") this.game.stack.replace(new MapScreen(this.game)); }
draw(buffer) { buffer.write(1, 1, "Chronicles of Arcanum"); buffer.write(1, 3, "n: new game"); }}
class MapScreen extends Screen { static ROWS = [ "########################", "#..@......~~~..........#", "#.........~~~..........#", "########################", ];
constructor(game) { super(game, "Map"); }
handle(command) { if (command === "i") this.game.stack.push(new InventoryScreen(this.game)); if (command === "f" && this.game.wolf > 0) this.game.stack.push(new BattleScreen(this.game)); }
draw(buffer) { MapScreen.ROWS.forEach((row, y) => buffer.write(0, y, row)); if (this.game.wolf > 0) buffer.write(8, 2, "w"); buffer.write(1, 4, `Life: ${this.game.life}`); }}
class BattleScreen extends Screen { constructor(game) { super(game, "Battle"); }
handle(command) { if (command === "i") this.game.stack.push(new InventoryScreen(this.game)); if (command === "a") { this.game.wolf--; if (this.game.wolf === 0) this.game.stack.pop(); } }
// Every turn of the battle, the wolf bites. update() { if (this.game.wolf > 0) this.game.life--; }
draw(buffer) { buffer.box(0, 0, TextBuffer.W, TextBuffer.H); buffer.write(2, 1, `Wolf: ${this.game.wolf}`); buffer.write(2, 2, `Life: ${this.game.life}`); buffer.write(2, 3, "a: hit i: inventory"); }}
class InventoryScreen extends Screen { constructor(game) { super(game, "Inventory"); }
isTransparent() { return true; }
handle(command) { if (command === "x") this.game.stack.pop(); }
draw(buffer) { buffer.box(12, 1, 11, 3); buffer.write(13, 2, "Potion x2"); }}
function show(game, buffer) { console.log(game.stack.names()); buffer.clear(); game.stack.draw(buffer); buffer.print();}
const game = new Game();const buffer = new TextBuffer();game.stack.push(new TitleScreen(game));show(game, buffer);const rl = createInterface({ input: process.stdin });for await (const line of rl) { const command = line.trim(); console.log(`> ${command}`); const top = game.stack.top(); top.handle(command); top.update(); show(game, buffer);}More tasks
- Hero creation. Between the title screen and the map, add a screen that asks for a name. Both transitions use replace: there is no way back from the hero.
- Pause.
popens an opaque "Pause" screen from any screen. Check that the wolf does not bite while the game is paused. - Stack guard. Make
poprefuse when only one screen is left on the stack, and print why.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Polymorphism · Arrays: push, splice and sort · Stack, queue and linked list · Arrow functions
- Tasks on this topic: Chronicles of Arcanum