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} */.