index.html
Required. This is your markup, which should be plain HTML.
Stage games can be played in the terminal and that's great though adding your own look and feel, with audio, images and graphic interface can really bring them to life.
Your GUI files belong to a folder, which can be defined as follows:
gui: gui/
When you run stage compile, everything in that folder is carried into your game's
.stg file. There are three files with fixed names, and then whatever images, fonts and
sounds you want to use:
index.htmlRequired. This is your markup, which should be plain HTML.
style.cssRequired. Your game's styles.
script.jsOptional, though you'll almost always want one to add interactivity and communicate with the engine.
anything elseA PNG, JPEG or WebP image, a WOFF or WOFF2 font, or a WAV, Ogg or MP3 sound, a TXT file, in any folders you like.
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 engine's graphics API is available at window.Engine.gui and below are the available functions.
engine.on('turnChanged')Hands you every turn as it happens. This is where nearly all of your GUI's drawing lives. See What's in a turn.
engine.turnThe latest turn, or null before the game has begun.
engine.transcriptEverything that's been said in this playthrough, oldest first. Handy when you'd rather redraw the whole story than add each turn's lines as they come.
engine.preferences engine.on('preferencesChanged')Any preferences that the player has set in the app, available to your GUI.
engine.isResumable engine.on('resumableChanged')
Whether there's a save to come back to. If there is, begin() picks up from it, so you might
show a Continue button; restart() always starts fresh.
engine.media
Every sound and image your game declares, as sounds and images. A turn
already carries whatever it plays and shows, so you'll mostly use this to know what a game has at all,
say to hide a sound switch in a game with no sound.
engine.verbsThe verbs defined and configured for your game, along with their synonyms available.
engine.commands
Stage's own commands, which are the same for every game - save, inventory, etc.
engine.platform
macos, windows or linux, for when you'd like to tweak your
GUI for a specific platform. It's null when there's no platform set, such as a
browser window, so make sure your GUI works without one.
A turn is one plain object, and every part of it is there on every turn, so there's nothing to check for before you read it. Pull out the parts you need and draw them:
engine.on('turnChanged', (turn) => {
const { response, scene, media, achievements, metadata } = turn;
// draw it
});
response
What the game said back this turn, in order. Each line has some text and a
voice: game for your prose, engine for Stage itself speaking,
like Saved., and player for what they typed. Text can hold several paragraphs,
separated by blank lines.
sceneWhere the player is: an id, and the title if you gave the scene one.
measuresThe player's own measures, each with its id, value, min and max.
inventoryWhat 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. It's null if your game would rather not say.
achievements
Everything the player has earned in this playthrough. The ones this turn earned have
new set, so that's what to pop up a toast for.
finishedWhether the game has ended.
metadata
Facts about the turn that most GUIs never need. Its kind says what the input turned out
to be: turn for the game answering (with understood and moved),
journal, saves, and so on, or opened when a playthrough has just
begun, restarted or loaded.
trace
A breakdown of how the turn was handled, if you've switched it on in your game's
game's config with trace:. Otherwise it's null.
If your game declares sounds, every turn tells you what's heard in
media.sounds. Each sound has an id, a src you can hand straight to an
Audio, and loop. A looping sound is listed on every turn until your game stops it,
so keep yours in step with the list rather than keeping count yourself. That's what makes a loaded save
sound right without any extra work. A sound that plays once is only listed on the turn that played it, so
play it as the turn arrives.
const loops = new Map();
let playing = [];
const heard = () => engine.preferences.audio !== false;
// Start what should be going and stop what shouldn't.
const listen = () => {
const wanted = heard() ? playing : [];
for (const [id, sound] of loops) {
if (!wanted.some((one) => one.id === id)) {
sound.pause();
loops.delete(id);
}
}
for (const { id, src } of wanted) {
if (!loops.has(id)) {
const sound = new Audio(src);
sound.loop = true;
sound.play().catch(() => {});
loops.set(id, sound);
}
}
};
engine.on('turnChanged', ({ media }) => {
playing = media.sounds.filter((sound) => sound.loop);
for (const sound of media.sounds) {
if (!sound.loop && heard()) {
new Audio(sound.src).play().catch(() => {});
}
}
listen();
});
engine.on('preferencesChanged', listen);
Where and how a sound plays is up to you: fade it in, duck the music while something else plays, or ignore a sound altogether. Stage only says what's playing.
If your game declares images, every turn tells you what's
showing in media.images, in the order they were shown, so the last one is the latest. Each has a
src to draw and an alt saying what it shows, which is empty for a image that's
only decoration. A image stays in the list on every turn until your game hides it.
Where they go is entirely up to you. Here's the simplest layout, the one Stage's own GUI uses: keep the
latest image at the top, in an <img id="image">, and let the story scroll beneath it.
const image = document.getElementById('image');
engine.on('turnChanged', ({ media }) => {
const latest = media.images.at(-1);
image.hidden = !latest;
if (latest) {
image.src = latest.src;
image.alt = latest.alt;
}
});
To set images among the words instead, look at each image's before. For a image this
turn showed, it's the number of the line in response it goes before, so 0 is
before the first line and response.length is after the last. For a image that's still up
from an earlier turn, it's null, because you've already drawn it.
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.
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);
}
});
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.
How much room there is is up to whatever's drawing the game. The Stage app keeps twenty names per game
and 64 KB per value, in a plain file on the player's machine, so it's no place for secrets. A game
opened in a browser keeps nothing, and every call fails saying so. Either way, every call fails with a
reason rather than quietly doing nothing, so it's worth a .catch.
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'd like a prompt the player types into, it's yours: draw it however you like, an
<input> included, catch the keys yourself, and hand the line to
engine.submit(text) when they press Enter. Stage doesn't hold what's being typed for you,
because you already know.
Two calls tell Stage where the keyboard is, so its own shortcuts leave your keys alone:
engine.focus()Call it when your prompt takes the keyboard, say when the player taps it.
engine.blur()Call it when the player taps away from typing, so the keyboard goes back to the window.
If you write your GUI in TypeScript, or you just like your editor to know what's what, engine.d.ts describes everything on this page and the exact shape of a turn. It has no imports, so copying it in is all it takes:
import type { EngineGui } from './engine';
const engine: EngineGui = 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('../engine').EngineGui} */.