
Interactive fiction
A text-narrated adventure where you type commands in natural language, such as "open the door" or "go north", and the game describes what happens. The challenge is exploring, understanding the world from its descriptions and solving puzzles with the objects you find.
Also known as: IF, text adventure, parser game
Defining mechanics
- A command parser that turns what you type into a verb and some objects, and copes with synonyms and filler words.
- A map of rooms linked by exits named after directions (north, south, east, west, up, down).
- Objects you examine, pick up and combine to open the way, kept in an inventory.
- Text as the only output, because the narrator describes every place and answers every action, including the ones that cannot be done.
Gameplay loop
Read the description, imagine what to try, type a command and read the answer, which gives a clue, changes the world or leaves you where you were.
Our demo
Controls. Type on the keyboard and press Enter. Useful commands are look, go north (or just "north" or "n"), examine note, take key, open door, inventory and help. On a phone, tap the screen to bring up the keyboard.
Open full screenA five-room text adventure. You type commands such as "go north" or "take key", the parser understands them and the game tells you what happens. A note tells you where the key is, the key opens a door and behind the door is the chest.
Made with Kaplay, in about 194 lines of JavaScript.
How it's made
Setup
The game is created at a fixed size with no virtual buttons, because everything is played by typing. The constants set where the transcript goes and how many letters fit in a command.
// A text adventure needs no buttons: the whole game is the keyboard. kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [16, 20, 28] }); const TEXT_SIZE = 15; const LOG_TOP = 40; // the transcript lives between LOG_TOP and LOG_BOTTOM const LOG_BOTTOM = 312; const MAX_INPUT = 40;The world as data
Rooms are an object with a name, a description, exits (n, s, e, w) and the things inside. Things have the nouns that name them and what you see when you examine them. Since the code never names a room, you can draw another map by changing only this data.
// The world is DATA: rooms with exits and things. The code below never // mentions a room by name, so you can rewrite the map without touching it. const ROOMS = { hall: { name: { es: "Vestíbulo", en: "Hall" }, exits: { n: "library", e: "garden", w: "vault" }, things: ["door"], desc: { es: "Un vestíbulo silencioso, con el suelo de madera.", en: "A quiet hall with a wooden floor." } }, library: { name: { es: "Biblioteca", en: "Library" }, exits: { s: "hall" }, things: ["note"], desc: { es: "Estanterías hasta el techo. Hay una mesa con un papel encima.", en: "Shelves up to the ceiling. There is a table with a paper on it." } }, garden: { name: { es: "Jardín", en: "Garden" }, exits: { w: "hall", n: "shed" }, things: [], desc: { es: "Un jardín con la hierba alta. Al norte se ve un cobertizo.", en: "A garden with tall grass. A shed stands to the north." } }, shed: { name: { es: "Cobertizo", en: "Shed" }, exits: { s: "garden" }, things: ["key"], desc: { es: "Un cobertizo pequeño que huele a tierra.", en: "A small shed that smells of earth." } }, vault: { name: { es: "Cámara", en: "Vault" }, exits: { e: "hall" }, things: ["chest"], desc: { es: "Una sala sin ventanas. En el centro hay un cofre.", en: "A windowless room. A chest sits in the middle." } }, }; // Each thing has the nouns that name it, what you see when you examine it // and, optionally, a line that appears in the room description. const THINGS = { door: { nouns: { es: ["puerta"], en: ["door"] }, blocks: "w", look: () => (state.doorOpen ? { es: "La puerta está abierta.", en: "The door is open." } : { es: "Una puerta de roble, cerrada con llave.", en: "An oak door, locked." }), here: () => (state.doorOpen ? { es: "Al oeste, la puerta está abierta.", en: "To the west, the door stands open." } : { es: "Al oeste hay una puerta cerrada.", en: "There is a closed door to the west." }) }, note: { nouns: { es: ["nota", "papel"], en: ["note", "paper"] }, look: () => ({ es: "La nota dice: «Dejé la llave en el cobertizo».", en: "The note says: \"I left the key in the shed.\"" }) }, key: { nouns: { es: ["llave"], en: ["key"] }, portable: true, look: () => ({ es: "Una llave de hierro, pesada y fría.", en: "A heavy, cold iron key." }), here: () => ({ es: "Hay una llave en el suelo.", en: "There is a key on the floor." }) }, chest: { nouns: { es: ["cofre", "caja"], en: ["chest", "box"] }, look: () => ({ es: "Un cofre cerrado, sin cerradura.", en: "A closed chest with no lock." }) }, }; let state; // reset at the start of every game (scene "game")The command parser
This is the piece that defines the genre. It turns a typed sentence into a command with a verb, a direction and things. First it lowercases everything and strips accents and punctuation, so "Look!" and "look" are the same. Then it drops filler words (the, to, with…) and looks each word up in a dictionary. Synonyms are just several words for the same verb: "take", "get" and "grab" lead to the same action. A direction on its own, such as "north", is read as "go north".
// The parser turns a sentence into { verb, dir, things }. Steps: lowercase, // strip accents and punctuation, drop filler words and look each word up in // a dictionary. Synonyms are just several words for the same verb. const WORDS = { es: { look: ["mirar", "mira", "miro", "m", "ver", "examinar", "examina", "x", "leer", "lee"], go: ["ir", "ve", "voy", "vete", "andar", "anda", "camina", "caminar", "entrar", "entra"], take: ["coger", "coge", "cojo", "tomar", "toma", "agarrar", "agarra", "recoger", "recoge"], open: ["abrir", "abre", "abro", "usar", "usa", "uso"], inventory: ["inventario", "inv", "i"], help: ["ayuda"], }, en: { look: ["look", "l", "examine", "x", "inspect", "read", "check"], go: ["go", "walk", "move", "run", "enter"], take: ["take", "get", "grab", "pick", "carry"], open: ["open", "unlock", "use"], inventory: ["inventory", "inv", "i"], help: ["help"], }, }; const DIRS = { es: { norte: "n", n: "n", sur: "s", s: "s", este: "e", e: "e", oeste: "w", o: "w" }, en: { north: "n", n: "n", south: "s", s: "s", east: "e", e: "e", west: "w", w: "w" }, }; const DIR_NAMES = { es: { n: "norte", s: "sur", e: "este", w: "oeste" }, en: { n: "north", s: "south", e: "east", w: "west" } }; const FILLER = { es: ["el", "la", "los", "las", "un", "una", "al", "a", "de", "del", "hacia", "con", "en", "por", "mi"], en: ["the", "a", "an", "to", "at", "with", "on", "up", "in", "into", "my", "around"], }; function parse(sentence) { const words = sentence.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9 ]/g, " ").split(" ").filter((w) => w && !FILLER[lang].includes(w)); const cmd = { verb: null, dir: null, things: [] }; for (const w of words) { const verb = Object.keys(WORDS[lang]).find((v) => WORDS[lang][v].includes(w)); const thing = Object.keys(THINGS).find((id) => THINGS[id].nouns[lang].includes(w)); if (verb && !cmd.verb) cmd.verb = verb; else if (DIRS[lang][w]) cmd.dir = DIRS[lang][w]; else if (thing) cmd.things.push(thing); else if (!cmd.verb) cmd.verb = "?" + w; // an unknown first word } if (!cmd.verb && cmd.dir) cmd.verb = "go"; // "north" alone means "go north" return cmd; }The actions
Each verb is a small function that reads the state, changes it if needed and returns the game's answer. Only things in the room or in your inventory count. The door blocks the west exit until you open it with the key, and opening the chest wins the game. The answers to what you cannot do ("You can't see that here", "The door is locked") matter as much as the others, because they are what guide the player.
// Each verb is a small function that reads and changes the state and returns // what the game answers. Only things that are here or in your pocket count. const here = () => state.things[state.room]; const visible = (id) => here().includes(id) || state.inventory.includes(id); function describeRoom() { const room = ROOMS[state.room]; const extras = here().map((id) => THINGS[id].here?.()).filter(Boolean).map(t); const exits = Object.keys(room.exits).map((d) => DIR_NAMES[lang][d]).join(", "); return [`[title]${t(room.name)}[/title]`, t(room.desc), ...extras, t({ es: "Salidas: ", en: "Exits: " }) + exits].join("\n"); } const ACTIONS = { look: ({ things }) => (things.length ? (visible(things[0]) ? t(THINGS[things[0]].look()) : t({ es: "No ves eso aquí.", en: "You can't see that here." })) : describeRoom()), go: ({ dir }) => { const room = ROOMS[state.room]; if (!dir) return t({ es: "¿Hacia dónde? (norte, sur, este, oeste)", en: "Which way? (north, south, east, west)" }); if (!room.exits[dir]) return t({ es: "No puedes ir por ahí.", en: "You can't go that way." }); if (here().some((id) => THINGS[id].blocks === dir) && !state.doorOpen) return t({ es: "La puerta está cerrada con llave.", en: "The door is locked." }); state.room = room.exits[dir]; return describeRoom(); }, take: ({ things }) => { const id = things[0]; if (!id) return t({ es: "¿Coger qué?", en: "Take what?" }); if (state.inventory.includes(id)) return t({ es: "Ya lo llevas.", en: "You already have it." }); if (!visible(id)) return t({ es: "No ves eso aquí.", en: "You can't see that here." }); if (!THINGS[id].portable) return t({ es: "No puedes llevarte eso.", en: "You can't carry that." }); state.things[state.room] = here().filter((x) => x !== id); state.inventory.push(id); return t({ es: "Cogido.", en: "Taken." }); }, open: ({ things }) => { const id = things.find((x) => x === "door" || x === "chest"); // "use key on door" also works if (!id) return t({ es: "¿Abrir qué?", en: "Open what?" }); if (!visible(id)) return t({ es: "No ves eso aquí.", en: "You can't see that here." }); if (id === "chest") return (state.won = true), t({ es: "Levantas la tapa: ¡el cofre está lleno de monedas!", en: "You lift the lid: the chest is full of coins!" }); if (state.doorOpen) return t({ es: "Ya está abierta.", en: "It's already open." }); if (!state.inventory.includes("key")) return t({ es: "Está cerrada con llave.", en: "It's locked." }); state.doorOpen = true; return t({ es: "Giras la llave y la puerta se abre.", en: "You turn the key and the door opens." }); }, inventory: () => (state.inventory.length ? t({ es: "Llevas: ", en: "You carry: " }) + state.inventory.map((id) => THINGS[id].nouns[lang][0]).join(", ") : t({ es: "No llevas nada.", en: "You carry nothing." })), help: () => t({ es: "Prueba: mirar, ir norte, coger llave, abrir puerta, inventario.", en: "Try: look, go north, take key, open door, inventory." }), }; function run(sentence) { const cmd = parse(sentence); if (!cmd.verb) return ""; state.moves++; if (cmd.verb.startsWith("?")) return t({ es: `No sé qué es «${cmd.verb.slice(1)}». Escribe «ayuda».`, en: `I don't know the word "${cmd.verb.slice(1)}". Type "help".` }); return ACTIONS[cmd.verb](cmd); }Typing on a phone
A phone only shows its keyboard when there is a real text field. That is why an invisible <input> takes the focus when you tap the screen and copies what you type into the game's line. The field handles its own keys, so the keyboard does not close after each letter.
// Phones only open their keyboard for a real text field, so an invisible // <input> catches what is typed there and copies it to the game's line. const field = document.createElement("input"); Object.assign(field, { autocomplete: "off", autocapitalize: "none", spellcheck: false, enterKeyHint: "send" }); field.style.cssText = "position:fixed;left:0;bottom:0;width:1px;height:1px;opacity:0;border:0;font-size:16px"; document.body.append(field); let typed = ""; // the line being written, from the physical keyboard or from the field let onEnter = () => {}; // what Enter does in the current scene field.addEventListener("input", () => (typed = field.value.slice(0, MAX_INPUT))); field.addEventListener("keydown", (e) => { e.stopPropagation(); // the field handles its own keys if (e.key === "Enter") onEnter(); }); if (matchMedia("(pointer: coarse)").matches) document.addEventListener("pointerdown", () => field.focus());The transcript
Everything said stays in a single text, with your command in blue and each room name in gold (using Kaplay's style tags). When it no longer fits, the oldest paragraphs are dropped, like a scrolling terminal. The current room and the move count are shown at the top.
// The transcript is one text with everything said so far. When it no // longer fits, the oldest paragraphs are dropped, like a scrolling terminal. const styles = { title: { color: rgb(255, 210, 120) }, cmd: { color: rgb(130, 200, 255) } }; const log = []; const logText = add([text("", { size: TEXT_SIZE, font: FONT, width: 600, lineSpacing: 3, styles }), pos(20, LOG_TOP)]); function say(paragraph) { log.push(paragraph); const opts = { size: TEXT_SIZE, font: FONT, width: 600, lineSpacing: 3, styles }; while (log.length > 1 && formatText({ ...opts, text: log.join("\n\n") }).height > LOG_BOTTOM - LOG_TOP) log.shift(); logText.text = log.join("\n\n"); } const hud = add([text("", { size: 13, font: FONT }), pos(20, 14), color(150, 160, 180)]); add([rect(600, 1), pos(20, 34), color(60, 70, 90)]); add([rect(600, 1), pos(20, LOG_BOTTOM + 6), color(60, 70, 90)]); const prompt = add([text("", { size: TEXT_SIZE + 1, font: FONT }), pos(20, LOG_BOTTOM + 16), color(130, 200, 255)]); onUpdate(() => { hud.text = `${t(ROOMS[state.room].name)} · ${t({ es: "Turnos", en: "Moves" })}: ${state.moves}`; prompt.text = "> " + typed + (time() % 1 < 0.5 ? "_" : ""); }); say(describeRoom());The command line
onCharInput receives each letter from the physical keyboard and Backspace deletes the last one. Enter sends the sentence to the parser, echoes it in the transcript and shows the answer. When you open the chest, the game waits a moment and moves to the final scene.
// The physical keyboard writes letter by letter; Enter sends the sentence. const submit = () => { const sentence = typed.trim(); typed = field.value = ""; if (!sentence || state.won) return; say(`[cmd]> ${sentence.replace(/[[\]\\]/g, "")}[/cmd]\n` + run(sentence)); if (state.won) wait(2.5, () => go("end", state.moves)); }; onEnter = submit; onCharInput((ch) => { if (typed.length < MAX_INPUT && (typed || ch !== " ")) typed += ch; field.value = typed; }); onKeyPressRepeat("backspace", () => (field.value = typed = typed.slice(0, -1))); onKeyPress("enter", submit);Scenes
There are three scenes (title, game and end). Each new game rebuilds the state from the world data, so the key goes back to its place without cleaning anything up by hand.
function screen(title, subtitle) { add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560, lineSpacing: 4 }), pos(center().add(0, 30)), anchor("center"), color(190, 200, 230)]); onEnter = () => go("game"); onKeyPress(["enter", "space"], onEnter); onClick(onEnter); } scene("title", () => screen( t({ es: "Ficción interactiva", en: "Interactive fiction" }), t({ es: "Escribe órdenes y pulsa Enter: «mirar», «ir norte», «coger llave», «abrir puerta», «inventario».\nEncuentra lo que guarda la casa.\n\nPulsa Enter o toca para empezar.", en: "Type commands and press Enter: \"look\", \"go north\", \"take key\", \"open door\", \"inventory\".\nFind what the house is hiding.\n\nPress Enter or tap to start.", }), ), ); scene("end", (moves) => screen( t({ es: "¡Encontraste el tesoro!", en: "You found the treasure!" }), t({ es: `Lo lograste en ${moves} turnos. Pulsa Enter para jugar otra vez.`, en: `You did it in ${moves} moves. Press Enter to play again.` }), ), ); go("title");
What we simplified
- The parser only understands a verb, a direction and the names of things. It ignores the other words, so "open the door with the key, please" works, but it cannot tell apart sentences with two objects such as "put the key in the chest".
- There are only five verbs (look, go, take, open and inventory), and you cannot drop objects or save the game.
- Each language has its own dictionary, so the Spanish version understands Spanish commands and the English version understands English ones.
To modify it
- Add a sixth room to the map and a new object needed to get into it (for example, a torch for a dark cellar).
- Add the verb "drop" to leave objects in the room you are in.
- Make the parser accept "take all" and pick up every portable object in the room.
- Have a classmate who does not know the map play a game, and write down every command the game did not understand. Use that list to add synonyms.
Full code
// Interactive fiction demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { lang, t, FONT, pixelDensity } from "../common.js";
// #region setup
// A text adventure needs no buttons: the whole game is the keyboard.
kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [16, 20, 28] });
const TEXT_SIZE = 15;
const LOG_TOP = 40; // the transcript lives between LOG_TOP and LOG_BOTTOM
const LOG_BOTTOM = 312;
const MAX_INPUT = 40;
// #endregion
// #region world
// The world is DATA: rooms with exits and things. The code below never
// mentions a room by name, so you can rewrite the map without touching it.
const ROOMS = {
hall: { name: { es: "Vestíbulo", en: "Hall" }, exits: { n: "library", e: "garden", w: "vault" }, things: ["door"],
desc: { es: "Un vestíbulo silencioso, con el suelo de madera.", en: "A quiet hall with a wooden floor." } },
library: { name: { es: "Biblioteca", en: "Library" }, exits: { s: "hall" }, things: ["note"],
desc: { es: "Estanterías hasta el techo. Hay una mesa con un papel encima.", en: "Shelves up to the ceiling. There is a table with a paper on it." } },
garden: { name: { es: "Jardín", en: "Garden" }, exits: { w: "hall", n: "shed" }, things: [],
desc: { es: "Un jardín con la hierba alta. Al norte se ve un cobertizo.", en: "A garden with tall grass. A shed stands to the north." } },
shed: { name: { es: "Cobertizo", en: "Shed" }, exits: { s: "garden" }, things: ["key"],
desc: { es: "Un cobertizo pequeño que huele a tierra.", en: "A small shed that smells of earth." } },
vault: { name: { es: "Cámara", en: "Vault" }, exits: { e: "hall" }, things: ["chest"],
desc: { es: "Una sala sin ventanas. En el centro hay un cofre.", en: "A windowless room. A chest sits in the middle." } },
};
// Each thing has the nouns that name it, what you see when you examine it
// and, optionally, a line that appears in the room description.
const THINGS = {
door: { nouns: { es: ["puerta"], en: ["door"] }, blocks: "w",
look: () => (state.doorOpen ? { es: "La puerta está abierta.", en: "The door is open." } : { es: "Una puerta de roble, cerrada con llave.", en: "An oak door, locked." }),
here: () => (state.doorOpen ? { es: "Al oeste, la puerta está abierta.", en: "To the west, the door stands open." } : { es: "Al oeste hay una puerta cerrada.", en: "There is a closed door to the west." }) },
note: { nouns: { es: ["nota", "papel"], en: ["note", "paper"] },
look: () => ({ es: "La nota dice: «Dejé la llave en el cobertizo».", en: "The note says: \"I left the key in the shed.\"" }) },
key: { nouns: { es: ["llave"], en: ["key"] }, portable: true,
look: () => ({ es: "Una llave de hierro, pesada y fría.", en: "A heavy, cold iron key." }),
here: () => ({ es: "Hay una llave en el suelo.", en: "There is a key on the floor." }) },
chest: { nouns: { es: ["cofre", "caja"], en: ["chest", "box"] },
look: () => ({ es: "Un cofre cerrado, sin cerradura.", en: "A closed chest with no lock." }) },
};
let state; // reset at the start of every game (scene "game")
// #endregion
// #region parser
// The parser turns a sentence into { verb, dir, things }. Steps: lowercase,
// strip accents and punctuation, drop filler words and look each word up in
// a dictionary. Synonyms are just several words for the same verb.
const WORDS = {
es: {
look: ["mirar", "mira", "miro", "m", "ver", "examinar", "examina", "x", "leer", "lee"],
go: ["ir", "ve", "voy", "vete", "andar", "anda", "camina", "caminar", "entrar", "entra"],
take: ["coger", "coge", "cojo", "tomar", "toma", "agarrar", "agarra", "recoger", "recoge"],
open: ["abrir", "abre", "abro", "usar", "usa", "uso"],
inventory: ["inventario", "inv", "i"],
help: ["ayuda"],
},
en: {
look: ["look", "l", "examine", "x", "inspect", "read", "check"],
go: ["go", "walk", "move", "run", "enter"],
take: ["take", "get", "grab", "pick", "carry"],
open: ["open", "unlock", "use"],
inventory: ["inventory", "inv", "i"],
help: ["help"],
},
};
const DIRS = {
es: { norte: "n", n: "n", sur: "s", s: "s", este: "e", e: "e", oeste: "w", o: "w" },
en: { north: "n", n: "n", south: "s", s: "s", east: "e", e: "e", west: "w", w: "w" },
};
const DIR_NAMES = { es: { n: "norte", s: "sur", e: "este", w: "oeste" }, en: { n: "north", s: "south", e: "east", w: "west" } };
const FILLER = {
es: ["el", "la", "los", "las", "un", "una", "al", "a", "de", "del", "hacia", "con", "en", "por", "mi"],
en: ["the", "a", "an", "to", "at", "with", "on", "up", "in", "into", "my", "around"],
};
function parse(sentence) {
const words = sentence.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9 ]/g, " ").split(" ").filter((w) => w && !FILLER[lang].includes(w));
const cmd = { verb: null, dir: null, things: [] };
for (const w of words) {
const verb = Object.keys(WORDS[lang]).find((v) => WORDS[lang][v].includes(w));
const thing = Object.keys(THINGS).find((id) => THINGS[id].nouns[lang].includes(w));
if (verb && !cmd.verb) cmd.verb = verb;
else if (DIRS[lang][w]) cmd.dir = DIRS[lang][w];
else if (thing) cmd.things.push(thing);
else if (!cmd.verb) cmd.verb = "?" + w; // an unknown first word
}
if (!cmd.verb && cmd.dir) cmd.verb = "go"; // "north" alone means "go north"
return cmd;
}
// #endregion
// #region actions
// Each verb is a small function that reads and changes the state and returns
// what the game answers. Only things that are here or in your pocket count.
const here = () => state.things[state.room];
const visible = (id) => here().includes(id) || state.inventory.includes(id);
function describeRoom() {
const room = ROOMS[state.room];
const extras = here().map((id) => THINGS[id].here?.()).filter(Boolean).map(t);
const exits = Object.keys(room.exits).map((d) => DIR_NAMES[lang][d]).join(", ");
return [`[title]${t(room.name)}[/title]`, t(room.desc), ...extras, t({ es: "Salidas: ", en: "Exits: " }) + exits].join("\n");
}
const ACTIONS = {
look: ({ things }) => (things.length ? (visible(things[0]) ? t(THINGS[things[0]].look()) : t({ es: "No ves eso aquí.", en: "You can't see that here." })) : describeRoom()),
go: ({ dir }) => {
const room = ROOMS[state.room];
if (!dir) return t({ es: "¿Hacia dónde? (norte, sur, este, oeste)", en: "Which way? (north, south, east, west)" });
if (!room.exits[dir]) return t({ es: "No puedes ir por ahí.", en: "You can't go that way." });
if (here().some((id) => THINGS[id].blocks === dir) && !state.doorOpen) return t({ es: "La puerta está cerrada con llave.", en: "The door is locked." });
state.room = room.exits[dir];
return describeRoom();
},
take: ({ things }) => {
const id = things[0];
if (!id) return t({ es: "¿Coger qué?", en: "Take what?" });
if (state.inventory.includes(id)) return t({ es: "Ya lo llevas.", en: "You already have it." });
if (!visible(id)) return t({ es: "No ves eso aquí.", en: "You can't see that here." });
if (!THINGS[id].portable) return t({ es: "No puedes llevarte eso.", en: "You can't carry that." });
state.things[state.room] = here().filter((x) => x !== id);
state.inventory.push(id);
return t({ es: "Cogido.", en: "Taken." });
},
open: ({ things }) => {
const id = things.find((x) => x === "door" || x === "chest"); // "use key on door" also works
if (!id) return t({ es: "¿Abrir qué?", en: "Open what?" });
if (!visible(id)) return t({ es: "No ves eso aquí.", en: "You can't see that here." });
if (id === "chest") return (state.won = true), t({ es: "Levantas la tapa: ¡el cofre está lleno de monedas!", en: "You lift the lid: the chest is full of coins!" });
if (state.doorOpen) return t({ es: "Ya está abierta.", en: "It's already open." });
if (!state.inventory.includes("key")) return t({ es: "Está cerrada con llave.", en: "It's locked." });
state.doorOpen = true;
return t({ es: "Giras la llave y la puerta se abre.", en: "You turn the key and the door opens." });
},
inventory: () => (state.inventory.length ? t({ es: "Llevas: ", en: "You carry: " }) + state.inventory.map((id) => THINGS[id].nouns[lang][0]).join(", ") : t({ es: "No llevas nada.", en: "You carry nothing." })),
help: () => t({ es: "Prueba: mirar, ir norte, coger llave, abrir puerta, inventario.", en: "Try: look, go north, take key, open door, inventory." }),
};
function run(sentence) {
const cmd = parse(sentence);
if (!cmd.verb) return "";
state.moves++;
if (cmd.verb.startsWith("?")) return t({ es: `No sé qué es «${cmd.verb.slice(1)}». Escribe «ayuda».`, en: `I don't know the word "${cmd.verb.slice(1)}". Type "help".` });
return ACTIONS[cmd.verb](cmd);
}
// #endregion
// #region touch-typing
// Phones only open their keyboard for a real text field, so an invisible
// <input> catches what is typed there and copies it to the game's line.
const field = document.createElement("input");
Object.assign(field, { autocomplete: "off", autocapitalize: "none", spellcheck: false, enterKeyHint: "send" });
field.style.cssText = "position:fixed;left:0;bottom:0;width:1px;height:1px;opacity:0;border:0;font-size:16px";
document.body.append(field);
let typed = ""; // the line being written, from the physical keyboard or from the field
let onEnter = () => {}; // what Enter does in the current scene
field.addEventListener("input", () => (typed = field.value.slice(0, MAX_INPUT)));
field.addEventListener("keydown", (e) => {
e.stopPropagation(); // the field handles its own keys
if (e.key === "Enter") onEnter();
});
if (matchMedia("(pointer: coarse)").matches) document.addEventListener("pointerdown", () => field.focus());
// #endregion
scene("game", () => {
// The state is everything that can change; ROOMS keeps the starting layout.
const things = Object.fromEntries(Object.entries(ROOMS).map(([id, r]) => [id, [...r.things]]));
state = { room: "hall", things, inventory: [], doorOpen: false, moves: 0, won: false };
typed = field.value = "";
// #region transcript
// The transcript is one text with everything said so far. When it no
// longer fits, the oldest paragraphs are dropped, like a scrolling terminal.
const styles = { title: { color: rgb(255, 210, 120) }, cmd: { color: rgb(130, 200, 255) } };
const log = [];
const logText = add([text("", { size: TEXT_SIZE, font: FONT, width: 600, lineSpacing: 3, styles }), pos(20, LOG_TOP)]);
function say(paragraph) {
log.push(paragraph);
const opts = { size: TEXT_SIZE, font: FONT, width: 600, lineSpacing: 3, styles };
while (log.length > 1 && formatText({ ...opts, text: log.join("\n\n") }).height > LOG_BOTTOM - LOG_TOP) log.shift();
logText.text = log.join("\n\n");
}
const hud = add([text("", { size: 13, font: FONT }), pos(20, 14), color(150, 160, 180)]);
add([rect(600, 1), pos(20, 34), color(60, 70, 90)]);
add([rect(600, 1), pos(20, LOG_BOTTOM + 6), color(60, 70, 90)]);
const prompt = add([text("", { size: TEXT_SIZE + 1, font: FONT }), pos(20, LOG_BOTTOM + 16), color(130, 200, 255)]);
onUpdate(() => {
hud.text = `${t(ROOMS[state.room].name)} · ${t({ es: "Turnos", en: "Moves" })}: ${state.moves}`;
prompt.text = "> " + typed + (time() % 1 < 0.5 ? "_" : "");
});
say(describeRoom());
// #endregion
// #region input
// The physical keyboard writes letter by letter; Enter sends the sentence.
const submit = () => {
const sentence = typed.trim();
typed = field.value = "";
if (!sentence || state.won) return;
say(`[cmd]> ${sentence.replace(/[[\]\\]/g, "")}[/cmd]\n` + run(sentence));
if (state.won) wait(2.5, () => go("end", state.moves));
};
onEnter = submit;
onCharInput((ch) => {
if (typed.length < MAX_INPUT && (typed || ch !== " ")) typed += ch;
field.value = typed;
});
onKeyPressRepeat("backspace", () => (field.value = typed = typed.slice(0, -1)));
onKeyPress("enter", submit);
// #endregion
});
// #region scenes
function screen(title, subtitle) {
add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 50)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560, lineSpacing: 4 }), pos(center().add(0, 30)), anchor("center"), color(190, 200, 230)]);
onEnter = () => go("game");
onKeyPress(["enter", "space"], onEnter);
onClick(onEnter);
}
scene("title", () =>
screen(
t({ es: "Ficción interactiva", en: "Interactive fiction" }),
t({
es: "Escribe órdenes y pulsa Enter: «mirar», «ir norte», «coger llave», «abrir puerta», «inventario».\nEncuentra lo que guarda la casa.\n\nPulsa Enter o toca para empezar.",
en: "Type commands and press Enter: \"look\", \"go north\", \"take key\", \"open door\", \"inventory\".\nFind what the house is hiding.\n\nPress Enter or tap to start.",
}),
),
);
scene("end", (moves) =>
screen(
t({ es: "¡Encontraste el tesoro!", en: "You found the treasure!" }),
t({ es: `Lo lograste en ${moves} turnos. Pulsa Enter para jugar otra vez.`, en: `You did it in ${moves} moves. Press Enter to play again.` }),
),
);
go("title");
// #endregionDesign challenges
- Anticipating what the player will type. Every reasonable command the game does not understand breaks the illusion, so it needs many synonyms and answers for the unexpected.
- Writing descriptions that reveal what matters without spelling everything out, so players discover the puzzles themselves.
- Avoiding "guess the verb", when the solution is clear but only works with one exact word.
- Keeping players from getting lost, with a map that is easy to remember, exits always announced and a way to ask for help.
History
- 1976OriginColossal Cave Adventure
Colossal Cave Adventure, by Will Crowther (1976) and expanded by Don Woods in 1977, is the first well-known example of interactive fiction.
Colossal Cave Adventure (Wikipedia) - 1977Zork
Zork begins at MIT, with a parser that understands full sentences rather than just Adventure's verb–noun pairs.
Zork (Wikipedia) - 1978Adventureland
Scott Adams writes Adventureland for the TRS-80, a two-word-command adventure for home computers, and founds Adventure International to publish more.
Adventureland (Wikipedia) - 1984
Yenght, by Nacho Ruiz, is released by Dinamic for the ZX Spectrum, the first text adventure on the Spanish market.
Yenght (Wikipedia en español) - 1984The Hitchhiker's Guide to the Galaxy
Infocom releases The Hitchhiker's Guide to the Galaxy, written by Douglas Adams with Steve Meretzky, one of the best-known titles of the genre's commercial era.
The Hitchhiker's Guide to the Galaxy (video game) (Wikipedia) - 1993Curses
Graham Nelson releases Inform, a language for writing interactive fiction, together with Curses, the first game made with it. It opens the era of hobbyist authors who publish for free.
Curses (Wikipedia) - 1995
The first Interactive Fiction Competition (IFComp) is held, a yearly contest for short works that still runs today.
Interactive Fiction Competition (Wikipedia) - 1998Photopia
Adam Cadre's Photopia wins IFComp with a story that has almost no puzzles, showing the genre can centre on storytelling.
Photopia (Wikipedia) - 1998
José Luis Díaz releases InformATE, a library for writing Inform adventures that understand commands in Spanish.
Aventura conversacional (Wikipedia en español) - 2006
Graham Nelson presents Inform 7, in which the program itself is written in sentences of near-natural English.
Inform (Wikipedia) - 2007Lost Pig
Admiral Jota's Lost Pig wins IFComp with a narrator who talks like a clumsy orc and a parser that answers almost any command.
Lost Pig (Wikipedia)
Reference games
1976
Colossal Cave Adventure
You explore a cave system in search of treasure by typing one- or two-word commands such as "go north" or "get lamp". It is the first well-known text adventure and gave the adventure genre its name.
1977
Zork
You explore an underground empire looking for treasure by typing full sentences such as "open mailbox". Its parser understood far richer commands than Colossal Cave's, and in 1980 it was split into Zork I, II and III.
1978
Adventureland
You search for thirteen treasures in a fantasy world by typing two-word commands such as "climb tree". It was Scott Adams's first program, and its success led him to found Adventure International, which published a series of similar adventures.
1984
The Hitchhiker's Guide to the Galaxy
An adaptation of Douglas Adams's comic novel, written by the author himself with Infocom's Steve Meretzky. You escape Earth just before it is demolished, and the game mocks you, lies to you and kills you often.
1993
Curses
You look for an old map in your family home's attic and end up lifting a curse that hangs over the family. It was the first game written in Inform, the language its author released that same year, and it is distributed for free.
1998
Photopia
A short story told in scenes that jump between characters and moments, with very few puzzles. It won the 1998 Interactive Fiction Competition and showed that the genre could focus on storytelling rather than puzzles.
2007
Lost Pig
You guide Grunk, an orc farmhand, to catch a pig that got away from him. The narrator speaks like Grunk, in simple, clumsy sentences, and the parser understands a huge range of commands. It won the 2007 Interactive Fiction Competition.
Exercise
Play the demo and write down every command it did not understand. Then add the missing synonyms to the dictionary and a sixth room with a new object needed to get in. Afterwards, compare your parser with Lost Pig's, which understands almost anything you type.