GUI
Stage games don't need any graphics, and they play just fine in a terminal. But if you'd like yours to have a face, this is how.
The folder
Put your GUI in a folder next to your game's config, and say where it is:
gui: gui/
When you run stage build, everything in that folder is carried into your game's
.stg file. There are three files with fixed names, and then whatever pictures, fonts and
sounds you want to use:
| File | What it's for |
|---|---|
| index.html | Required. Your page's markup. Write just what goes inside the page, not a whole document. |
| style.css | Required. Your styles. |
| script.js | Optional, though you'll almost always want one. Your script, which talks to Stage. |
| anything else | A PNG, JPEG or WebP picture, a WOFF or WOFF2 font, or a WAV or Ogg sound, in any folders you like. Nothing else is allowed: the build stops and tells you which file it didn't recognise. |
Each file can be up to 4 MB, and the whole folder up to 16 MB. To use a picture or a font, refer to it by
its path exactly as it sits in the folder, like <img src="banner.webp"> or
url('fonts/mono.woff2'), and Stage swaps the path for the file itself.
Your page runs in a sandbox. It can run its own scripts, but it can't reach the rest of the app, the player's
files or anything else on their machine. Everything goes through one door, Engine.gui, which is
the rest of this section.
If you'd rather build your GUI with a tool like Vite or React, that's fine: build into a folder and point
gui: at the result. The reference game for this, Abandoned Empire, keeps its source in
gui/, builds into gui-dist/ and says gui: gui-dist/.
A whole GUI in twenty lines
Here's the smallest useful one: it shows the story, the room's name, and a button for each way out. The
markup goes in index.html and the script in script.js.
<h1 id="title"></h1>
<pre id="story"></pre>
<div id="ways"></div>
const engine = window.Engine.gui;
const draw = (turn) => {
document.getElementById('story').textContent = turn.lines.map((line) => line.text).join('\n\n');
const reply = turn.reply;
document.getElementById('title').textContent = reply ? (reply.scene.title ?? reply.scene.id) : '';
const ways = document.getElementById('ways');
ways.replaceChildren();
for (const way of reply?.affordances?.navigation ?? []) {
const button = document.createElement('button');
button.textContent = way.name;
button.onclick = () => engine.submit(way.name);
ways.append(button);
}
};
engine.ready.then(() => {
engine.on('turnChanged', draw);
draw(engine.turn);
engine.begin();
});
That shape is worth learning, because every GUI has it. Wait for engine.ready, which is Stage
saying it has told you how things stand. Then listen for changes and draw what's there now. Then start the
game. Listening comes first on purpose: if your very first draw has a mistake in it, you'd rather still hear
about every turn after it than sit there deaf.
What Stage tells you
Everything here is something you can read at any time once engine.ready has settled, and an
event that tells you when it changes. Nothing is announced for how things stood when you arrived, so you
never have to worry about being too late to hear it: read it, then listen.
| Read it | Hear about it | What it is |
|---|---|---|
| engine.turn | turnChanged |
The latest turn and everything said so far: { lines, reply }. See
What's in a turn.
|
| engine.preferences | preferencesChanged | The player's global preferences, attributed to every game that runs in the app. |
| engine.typed | typedChanged | What's been typed so far. See Typing. |
| engine.isResumable | resumableChanged | Whether the player opened the game to pick up a save, which is what a title screen needs to say Continue instead of Play. |
| engine.platform |
ios, macos, windows or linux, for when a phone
needs a different layout from a desktop. It never changes, so there's no event.
|
|
| traced |
How the turn that just happened was worked out, for games that ask for it with
trace: in config.yml. A one-off, so there's nothing to read later.
|
To listen, use engine.on('turnChanged', fn). It gives you back a function that stops listening,
handy if your screen comes and goes. Ask for an event that doesn't exist and you'll get an error that lists
the ones that do, rather than silence.
What's in a turn
turn.lines is everything that's been said, oldest first. Each line has some
text and a voice: player for what they typed,
game for your prose, and stage for Stage itself speaking, like
Saved.. Text can hold several paragraphs, separated by blank lines.
turn.reply is where things stand after the turn, or null before the game has
begun. These are the parts you're most likely to reach for:
| Key | Description |
|---|---|
| scene | Where the player is: an id, and the title if you gave the scene one. |
| measures | The player's own measures, each with its id, value, min and max. |
| inventory | What they're carrying, with the game's own name for each thing and whatever it measures. |
| affordances |
What can be said where they stand: characters, objects,
navigation (the ways out) and topics. Each has a name, which is
what to type. Missing altogether if your game would rather not say.
|
| achievements | What the player has earned so far in this playthrough. |
| finished | Whether the game has ended. |
| outcome | What just happened, when something did, including any trace the game asked for. |
There's more, and all of it is described in the types file, which is the complete and exact shape.
One thing to watch for: lines only grows while a playthrough carries on. When the player starts
again or loads a save, the new playthrough's first turn arrives with fewer lines than you had
before. If you remember how much you've drawn, draw again from the start when that happens.
turn.reply.session.id changes too, if that's easier to watch for.
Starting, restarting and loading
Nothing starts until you say so, which is what lets you keep a title screen up for as long as you like. Each of these gives you back a promise that settles once the first turn is in, or fails with the reason. So you can show it, rather than guessing.
| Call | Description |
|---|---|
| engine.begin() |
Start the game for the first time. If the player opened it to continue a save
(engine.isResumable), that's where it starts. Asking a second time is refused: use
restart().
|
| engine.restart() | Start again from the beginning, whenever you like: from a title screen, a menu, or after the game has ended. Any save is left exactly where it is. |
| engine.saves() |
The game's saves, most recent first, each with a name, savedAt,
turns and an id. That includes saves made on the player's other devices, so it can
take a moment. savedAt and turns are null when a save can't say.
|
| engine.load(id) |
Start from one of those saves, whenever you like. The id is a mystery to you and that's
fine: just hand back what saves() gave you. If the save can't be opened, the promise fails and the
game carries on exactly as it was.
|
| engine.quit() | Give up the playthrough and close the window, the same as the window's own close button. |
A title screen that copes with all of it is only a few lines:
await engine.ready;
play.textContent = engine.isResumable ? 'Continue' : 'Play';
play.onclick = () => engine.begin();
again.hidden = !engine.isResumable;
again.onclick = () => engine.restart();
engine.saves().then((saves) => {
for (const save of saves) {
const row = document.createElement('button');
row.textContent = save.name;
row.onclick = () => engine.load(save.id).catch((error) => alert(error.message));
list.append(row);
}
});
Remembering things
Your GUI can keep a few things of its own for next time: whether the player likes the glowing monitor, which tab they had open, a bookmark. It's kept separately for each player and each game.
const crt = await engine.storage.get('crt', true); // true if nothing's kept yet
await engine.storage.set('crt', false);
await engine.storage.delete('crt');
get gives you the fallback you pass (or null) when nothing's stored under that
name. Because of that, you can't store null or undefined, since they couldn't be told
apart from nothing: use delete to take something away. Deleting something that isn't there is
fine.
There's room for twenty names per game and 64 KB per value, and it's a plain file on the player's machine, so
it's no place for secrets. Every call fails with a reason rather than quietly doing nothing, so it's worth a
.catch if you care.
Typing
The simplest way to take input is to call engine.submit('open door') from a click, which plays that
line exactly as if the player had typed it. You never need a text box at all.
If you do want a prompt the player types into, don't add your own <input>. On iPhone and iPad,
focusing a text box inside a page like this makes the whole page jump about when the keyboard opens, and
there's no way to stop it. So Stage keeps the one real text box, out of sight, and you draw a picture of it:
| Call | Description |
|---|---|
| engine.ownsPrompt() | Tell Stage you're drawing the prompt. Its own comes down for good. |
| engine.typed | What's been typed so far, for you to draw. Listen with typedChanged. |
| engine.focus() | Call it when the player taps your prompt, so the keyboard comes up. |
| engine.blur() | Call it when they tap away from typing, so the keyboard goes down. |
The types file
If you write your GUI in TypeScript, or you just like your editor to know what's what, stage-gui.d.ts describes everything on this page and the exact shape of a reply. It has no imports, so copying it in is all it takes:
import type { StageGui } from './stage-gui';
const engine: StageGui = window.Engine.gui;
Keep it out of the folder you named with gui:. Everything in that folder is carried into your
game, and a .d.ts isn't one of the things a GUI is allowed to carry, so the build would stop. If
you build your GUI from a source folder, as Abandoned Empire does, that source folder is the place.
For plain JavaScript, a comment above your script does the same job:
/** @type {import('../stage-gui').StageGui} */.