Skip to content
GameGenresEspañol
Adventure

Adventure

You progress by exploring places, talking to characters and solving puzzles with the items you pick up, and your reflexes do not matter. The challenge is understanding how the story's world works.

Also known as: adventure game, graphic adventure

Defining mechanics

  • Exploring a world split into places (rooms, screens or descriptions) that you move through at your own pace.
  • An inventory, that is, picking up items and using them on the scenery or combining them with each other.
  • An interface for giving orders: typing them ("open door") in text adventures, or picking a verb and clicking in point-and-click ones.
  • Conversations with characters, often with options, that give clues and move the story forward.

Gameplay loop

You look at a place, imagine a solution ("what if I use this here?"), try it, the world responds and a new place or a new part of the story opens up.

Our demo

Controls. Everything with the mouse or your finger. Pick the verb Look or Take and click on things. To use an item, tap it in the inventory and then tap where you want to use it. The arrows lead to another place.

Open full screen

A point-and-click adventure with three places (garden, workshop and study) in which you recover the notebook an inventor left in her locked study. It has two chained inventory puzzles, and you need to solve both to reach the notebook.

Made with Kaplay, in about 199 lines of JavaScript.

How it's made

  1. Setup

    The game is created at a fixed size. The keyboard is only used to leave the title screen, because the rest is played with clicks, like point-and-click adventures.

    // Everything is played with the mouse or a finger; the keyboard only starts the game.
    // That is why there are no on-screen touch buttons: you tap the scene itself.
    kaplay({
      width: 640,
      height: 360,
      letterbox: true, pixelDensity,
      background: [24, 22, 40],
      buttons: { start: { keyboard: ["space", "enter"] } },
    });
    const FLOOR_Y = 200; // height where the floor of every room begins
  2. The world as data

    Each room is a list of objects with a name, a shape, the text shown when you look at it and, where relevant, whether you can take it or which place it leads to. Some texts are functions that change with the state. Adding a place or an item means writing data, not programming.

    // The world is DATA: rooms with objects. Each object has a name, a shape,
    // what you are told when you look at it and, if relevant, whether you can
    // take it ("take") or which room it leads to ("exit").
    let gameState; // created at the start of every game (region "state")
    const ROOMS = {
      garden: {
        name: { es: "Jardín", en: "Garden" },
        enter: { es: "El jardín de la casa. La puerta del estudio está al fondo.", en: "The garden of the house. The study door is at the back." },
        sky: [120, 170, 210],
        floor: [90, 140, 80],
        objects: [
          { id: "tree", shape: "tree", x: 30, y: 20, w: 150, h: 210,
            name: { es: "árbol", en: "tree" },
            look: () => gameState.keyInTree
              ? { es: "Un árbol alto. ¡Hay una llave colgada de una rama!", en: "A tall tree. There's a key hanging from a branch!" }
              : { es: "Un árbol alto. Da buena sombra.", en: "A tall tree. It gives good shade." },
            noTake: { es: "La llave está muy alta. Necesito algo largo para alcanzarla.", en: "The key is too high. I need something long to reach it." } },
          { id: "key", shape: "key", x: 196, y: 214, w: 30, h: 16, take: true, color: [250, 210, 70],
            name: { es: "llave", en: "key" }, visible: () => gameState.keyFell,
            look: { es: "Una llave de hierro.", en: "An iron key." } },
          { id: "door", shape: "door", x: 330, y: 70, w: 80, h: 140, exit: "study",
            name: { es: "puerta del estudio", en: "study door" }, locked: () => !gameState.doorOpen,
            look: { es: "La puerta del estudio. Está cerrada con llave.", en: "The study door. It's locked." } },
          { id: "to-workshop", shape: "arrow", x: 566, y: 110, w: 60, h: 60, exit: "workshop", dir: 1, name: { es: "ir al taller", en: "go to the workshop" } },
        ],
      },
      workshop: {
        name: { es: "Taller", en: "Workshop" },
        enter: { es: "El taller huele a aceite y a madera.", en: "The workshop smells of oil and wood." },
        sky: [190, 170, 140],
        floor: [110, 90, 70],
        objects: [
          { id: "bench", x: 100, y: 130, w: 150, h: 80, color: [120, 90, 60],
            name: { es: "mesa de trabajo", en: "workbench" },
            look: { es: "Una mesa llena de tornillos y engranajes.", en: "A bench covered in screws and gears." } },
          { id: "pole", shape: "pole", x: 310, y: 40, w: 20, h: 170, take: true, color: [200, 160, 80],
            name: { es: "palo largo", en: "long pole" },
            look: { es: "Un palo largo de madera, apoyado en la pared.", en: "A long wooden pole, leaning on the wall." } },
          { id: "to-garden", shape: "arrow", x: 16, y: 110, w: 60, h: 60, exit: "garden", dir: -1, name: { es: "volver al jardín", en: "back to the garden" } },
        ],
      },
      study: {
        name: { es: "Estudio", en: "Study" },
        enter: { es: "El estudio, en penumbra.", en: "The study, in the half-light." },
        sky: [90, 70, 110],
        floor: [70, 50, 60],
        objects: [
          { id: "drawer", x: 110, y: 140, w: 200, h: 70, color: [150, 100, 70],
            name: { es: "cajonera", en: "chest of drawers" },
            look: { es: "Los cajones solo guardan lápices sin punta.", en: "The drawers only hold blunt pencils." } },
          { id: "notebook", shape: "notebook", x: 410, y: 160, w: 70, h: 44, take: true, color: [70, 130, 200],
            name: { es: "cuaderno", en: "notebook" },
            look: { es: "¡El cuaderno de la inventora! Es lo que vine a buscar.", en: "The inventor's notebook! This is what I came for." } },
          { id: "to-garden", shape: "arrow", x: 16, y: 110, w: 60, h: 60, exit: "garden", dir: -1, name: { es: "volver al jardín", en: "back to the garden" } },
        ],
      },
    };
  3. The puzzles

    An inventory puzzle is an "item > target" pair. Using the pole on the tree knocks the key down, and using the key on the door opens it. Since one puzzle sets up the next, they form a chain of dependencies, which is the skeleton of any adventure.

    // Puzzles are "inventory item > scene object" pairs.
    // Each one changes the world state and returns what the narrator says.
    const COMBOS = {
      "pole>tree": () => {
        if (!gameState.keyInTree) return { es: "Ya no hay nada en la rama.", en: "There's nothing left on the branch." };
        gameState.keyInTree = false;
        gameState.keyFell = true;
        return { es: "Sacudes la rama con el palo... ¡la llave cae al suelo!", en: "You shake the branch with the pole... the key falls to the ground!" };
      },
      "key>door": () => {
        gameState.doorOpen = true;
        gameState.inventory = gameState.inventory.filter((i) => i !== "key");
        return { es: "La llave gira con un clic. La puerta se abre.", en: "The key turns with a click. The door opens." };
      },
    };
  4. The world state

    Everything that changes while you play (where you are, what you carry, what you took and which doors you opened) lives in a single object. That makes it easy to restart the game and, later, to save it.

    // Everything that changes while you play fits in one object: that makes saving easy.
    gameState = { room: "garden", inventory: [], taken: [], keyInTree: true, keyFell: false, doorOpen: false };
    let [verb, selected] = ["look", null]; // selected: the inventory item you are using
    const value = (x) => (typeof x === "function" ? x() : x); // fixed text or text that depends on the state
    const findObject = (id) => Object.values(ROOMS).flatMap((r) => r.objects).find((o) => o.id === id);
  5. Drawing the places

    Each object is drawn with simple shapes and covered by an invisible rectangle that receives clicks: the hotspot, or sensitive area. Whenever the state changes, the room is cleared and rebuilt, so what you see always matches the data.

    // Each object is an invisible rectangle that receives clicks (the "hotspot")
    // plus a few simple shapes that draw it.
    function draw(o, open) {
      const c = rgb(...(o.color ?? [140, 100, 70]));
      const d = (comps) => add([...comps, "room"]);
      const { x, y, w, h } = o;
      if (o.shape === "tree") {
        d([rect(26, h - 90), pos(x + w / 2 - 13, y + 90), color(110, 75, 45)]);
        d([circle(68), pos(x + w / 2, y + 70), color(60, 130, 70)]);
        if (gameState.keyInTree) d([rect(18, 8), pos(x + w - 40, y + 40), color(250, 210, 70)]);
      } else if (o.shape === "door") {
        d([rect(w + 140, h + 20), pos(x - 70, y - 20), color(190, 180, 170)]); // house wall
        d([rect(w, h), pos(x, y), color(open ? rgb(20, 15, 25) : rgb(120, 70, 40))]);
        if (!open) d([circle(5), pos(x + w - 14, y + h / 2), color(240, 200, 80)]);
      } else if (o.shape === "arrow") {
        const p = o.dir > 0 ? [vec2(0, 0), vec2(w, h / 2), vec2(0, h)] : [vec2(w, 0), vec2(0, h / 2), vec2(w, h)];
        d([polygon(p), pos(x, y), color(255, 255, 255), opacity(0.6)]);
      } else if (o.shape === "pole") {
        d([rect(8, h), pos(x + w / 2 - 4, y), color(c)]);
      } else if (o.shape === "key") {
        d([circle(7), pos(x + 7, y + 8), color(c)]);
        d([rect(w - 10, 5), pos(x + 10, y + 6), color(c)]);
      } else if (o.shape === "notebook") {
        d([rect(w, h), pos(x, y), color(c)]);
        d([rect(6, h), pos(x, y), color(40, 70, 120)]); // spine
      } else {
        d([rect(w, h), pos(x, y), color(c), outline(2, rgb(0, 0, 0))]);
      }
    }
    
    function buildRoom() {
      destroyAll("room");
      const room = ROOMS[gameState.room];
      add([rect(640, FLOOR_Y), color(...room.sky), "room"]);
      add([rect(640, 270 - FLOOR_Y), pos(0, FLOOR_Y), color(...room.floor), "room"]);
      add([text(t(room.name), { size: 14, font: FONT }), pos(628, 60), anchor("topright"), opacity(0.8), "room"]);
      for (const o of room.objects) {
        if ((o.visible && !o.visible()) || gameState.taken.includes(o.id)) continue;
        draw(o, o.locked && !o.locked()); // fill: false → invisible, but its area still receives clicks
        add([rect(o.w, o.h, { fill: false }), pos(o.x, o.y), area(), "room", "hotspot", { data: o }]);
      }
    }
  6. Verbs and inventory

    The verbs and inventory slots are at the bottom, and the narration text is at the top. This is the classic point-and-click interface, where you first say what to do and then with what.

    // Bottom: the verbs (look, take) and the inventory. Top: the narration.
    add([rect(640, 90), pos(0, 270), color(30, 28, 50), z(5)]);
    add([rect(624, 44), pos(8, 8), color(0, 0, 0), opacity(0.6), z(5)]);
    const message = add([text("", { size: 15, font: FONT, width: 604 }), pos(18, 14), z(6)]);
    const statusLine = add([text("", { size: 14, font: FONT }), pos(320, 252), anchor("center"), z(6), color(255, 240, 180)]);
    const say = (txt) => (message.text = t(txt));
    
    const VERBS = [{ id: "look", name: { es: "Mirar", en: "Look" } }, { id: "take", name: { es: "Tomar", en: "Take" } }];
    VERBS.forEach((v, i) => {
      const b = add([rect(96, 32, { radius: 6 }), pos(14, 280 + i * 38), color(70, 70, 120), area(), z(6), "verb", { verb: v.id }]);
      b.add([text(t(v.name), { size: 16, font: FONT }), pos(48, 16), anchor("center")]);
    });
    
    function drawInventory() {
      destroyAll("slot");
      gameState.inventory.forEach((id, i) => {
        const o = findObject(id);
        const s = add([rect(110, 60, { radius: 6 }), pos(130 + i * 120, 285), color(selected === id ? rgb(120, 110, 40) : rgb(55, 52, 85)), area(), z(6), "slot", { item: id }]);
        s.add([circle(12), pos(55, 22), color(...o.color)]);
        s.add([text(t(o.name), { size: 13, font: FONT }), pos(55, 46), anchor("center")]);
      });
    }
  7. What happens on a click

    A single click handler decides what you touched, and the interact function resolves the action: combine an item, go through an exit, take or look. The status line previews what you would do ("Use long pole with tree"), a small aid that saves many blind clicks.

    // A single click handler decides what you touched: a verb, the inventory
    // or an object in the scene (the topmost one if they overlap).
    function interact(o) {
      if (selected) {
        const combo = COMBOS[`${selected}>${o.id}`];
        say(combo ? combo() : { es: "Eso no sirve ahí.", en: "That doesn't work there." });
        selected = null;
      } else if (o.exit && !(o.locked && o.locked())) {
        gameState.room = o.exit;
        say(ROOMS[o.exit].enter);
      } else if (verb === "take" && o.take) {
        gameState.taken.push(o.id);
        if (o.id === "notebook") return go("end");
        gameState.inventory.push(o.id);
        say({ es: `Tomas: ${t(o.name)}.`, en: `You take: ${t(o.name)}.` });
      } else if (verb === "take") {
        say(o.noTake ?? { es: "No puedo llevarme eso.", en: "I can't take that." });
      } else {
        say(value(o.look) ?? o.name);
      }
      buildRoom();
      drawInventory();
    }
    
    onClick(() => {
      const verbButton = get("verb").find((b) => b.isHovering());
      const slot = get("slot").find((s) => s.isHovering());
      const hotspot = get("hotspot").reverse().find((h) => h.isHovering());
      if (verbButton) [verb, selected] = [verbButton.verb, null];
      else if (slot) selected = selected === slot.item ? null : slot.item;
      else if (hotspot) return interact(hotspot.data);
      else selected = null;
      drawInventory();
    });
    
    // The status line says what a click would do: "Use long pole with tree".
    onUpdate(() => {
      const hotspot = get("hotspot").reverse().find((h) => h.isHovering());
      for (const b of get("verb")) b.color = b.verb === verb && !selected ? rgb(110, 110, 190) : rgb(70, 70, 120);
      const name = hotspot ? t(hotspot.data.name) : "";
      if (selected) statusLine.text = t({ es: "Usar ", en: "Use " }) + t(findObject(selected).name) + t({ es: " con ", en: " with " }) + name;
      else statusLine.text = hotspot?.data.exit ? name : `${t(VERBS.find((v) => v.id === verb).name)} ${name}`;
      setCursor(hotspot || get("verb").concat(get("slot")).some((b) => b.isHovering()) ? "pointer" : "default");
    });
    
    say({ es: "La inventora dejó su cuaderno en el estudio, y la puerta está cerrada. ¿Dónde estará la llave?", en: "The inventor left her notebook in the study, and the door is locked. Where could the key be?" });
    buildRoom();
    drawInventory();
  8. Scenes

    The game has three scenes, title, game and end. Taking the notebook leads to the end scene, and from there you can start again.

    function screen(title, subtitle, next) {
      add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
      add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
      onButtonPress("start", next);
      onClick(next);
    }
    
    scene("title", () => screen(t({ es: "La casa de la inventora", en: "The inventor's house" }),
      t({
        es: "Elige un verbo (Mirar o Tomar) y haz clic en las cosas. Para usar algo del inventario, tócalo y luego toca dónde quieres usarlo.\nHaz clic o pulsa espacio para empezar.",
        en: "Pick a verb (Look or Take) and click on things. To use something from your inventory, tap it and then tap where you want to use it.\nClick or press space to start.",
      }),
      () => go("game")));
    
    scene("end", () => screen(t({ es: "¡Encontraste el cuaderno!", en: "You found the notebook!" }),
      t({ es: "Ahora se puede terminar el invento. Haz clic o pulsa espacio para jugar otra vez.", en: "Now the invention can be finished. Click or press space to play again." }),
      () => go("game")));
    
    go("title");

What we simplified

  • There are only two verbs, Look and Take, plus using items. Lucasfilm's classic adventures had a dozen or more.
  • There is no protagonist walking around the scene and no conversations with characters; a click acts instantly.
  • Places and items are simple shapes, with no illustrations, and the game cannot be saved.

To modify it

  • Add a third puzzle, for example a watering can in the workshop that must be filled at a tap in the garden to water a plant that hides something.
  • Translate all the text into a third language. Add a new key (for example fr or qu) to every text and make the t function use it when the URL asks for it.
  • Add a character you can talk to, who gives a hint when the player has been stuck for a while.
Full code
// Adventure demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { t, FONT, pixelDensity } from "../common.js";

// #region setup
// Everything is played with the mouse or a finger; the keyboard only starts the game.
// That is why there are no on-screen touch buttons: you tap the scene itself.
kaplay({
  width: 640,
  height: 360,
  letterbox: true, pixelDensity,
  background: [24, 22, 40],
  buttons: { start: { keyboard: ["space", "enter"] } },
});
const FLOOR_Y = 200; // height where the floor of every room begins
// #endregion

// #region world
// The world is DATA: rooms with objects. Each object has a name, a shape,
// what you are told when you look at it and, if relevant, whether you can
// take it ("take") or which room it leads to ("exit").
let gameState; // created at the start of every game (region "state")
const ROOMS = {
  garden: {
    name: { es: "Jardín", en: "Garden" },
    enter: { es: "El jardín de la casa. La puerta del estudio está al fondo.", en: "The garden of the house. The study door is at the back." },
    sky: [120, 170, 210],
    floor: [90, 140, 80],
    objects: [
      { id: "tree", shape: "tree", x: 30, y: 20, w: 150, h: 210,
        name: { es: "árbol", en: "tree" },
        look: () => gameState.keyInTree
          ? { es: "Un árbol alto. ¡Hay una llave colgada de una rama!", en: "A tall tree. There's a key hanging from a branch!" }
          : { es: "Un árbol alto. Da buena sombra.", en: "A tall tree. It gives good shade." },
        noTake: { es: "La llave está muy alta. Necesito algo largo para alcanzarla.", en: "The key is too high. I need something long to reach it." } },
      { id: "key", shape: "key", x: 196, y: 214, w: 30, h: 16, take: true, color: [250, 210, 70],
        name: { es: "llave", en: "key" }, visible: () => gameState.keyFell,
        look: { es: "Una llave de hierro.", en: "An iron key." } },
      { id: "door", shape: "door", x: 330, y: 70, w: 80, h: 140, exit: "study",
        name: { es: "puerta del estudio", en: "study door" }, locked: () => !gameState.doorOpen,
        look: { es: "La puerta del estudio. Está cerrada con llave.", en: "The study door. It's locked." } },
      { id: "to-workshop", shape: "arrow", x: 566, y: 110, w: 60, h: 60, exit: "workshop", dir: 1, name: { es: "ir al taller", en: "go to the workshop" } },
    ],
  },
  workshop: {
    name: { es: "Taller", en: "Workshop" },
    enter: { es: "El taller huele a aceite y a madera.", en: "The workshop smells of oil and wood." },
    sky: [190, 170, 140],
    floor: [110, 90, 70],
    objects: [
      { id: "bench", x: 100, y: 130, w: 150, h: 80, color: [120, 90, 60],
        name: { es: "mesa de trabajo", en: "workbench" },
        look: { es: "Una mesa llena de tornillos y engranajes.", en: "A bench covered in screws and gears." } },
      { id: "pole", shape: "pole", x: 310, y: 40, w: 20, h: 170, take: true, color: [200, 160, 80],
        name: { es: "palo largo", en: "long pole" },
        look: { es: "Un palo largo de madera, apoyado en la pared.", en: "A long wooden pole, leaning on the wall." } },
      { id: "to-garden", shape: "arrow", x: 16, y: 110, w: 60, h: 60, exit: "garden", dir: -1, name: { es: "volver al jardín", en: "back to the garden" } },
    ],
  },
  study: {
    name: { es: "Estudio", en: "Study" },
    enter: { es: "El estudio, en penumbra.", en: "The study, in the half-light." },
    sky: [90, 70, 110],
    floor: [70, 50, 60],
    objects: [
      { id: "drawer", x: 110, y: 140, w: 200, h: 70, color: [150, 100, 70],
        name: { es: "cajonera", en: "chest of drawers" },
        look: { es: "Los cajones solo guardan lápices sin punta.", en: "The drawers only hold blunt pencils." } },
      { id: "notebook", shape: "notebook", x: 410, y: 160, w: 70, h: 44, take: true, color: [70, 130, 200],
        name: { es: "cuaderno", en: "notebook" },
        look: { es: "¡El cuaderno de la inventora! Es lo que vine a buscar.", en: "The inventor's notebook! This is what I came for." } },
      { id: "to-garden", shape: "arrow", x: 16, y: 110, w: 60, h: 60, exit: "garden", dir: -1, name: { es: "volver al jardín", en: "back to the garden" } },
    ],
  },
};
// #endregion

// #region puzzles
// Puzzles are "inventory item > scene object" pairs.
// Each one changes the world state and returns what the narrator says.
const COMBOS = {
  "pole>tree": () => {
    if (!gameState.keyInTree) return { es: "Ya no hay nada en la rama.", en: "There's nothing left on the branch." };
    gameState.keyInTree = false;
    gameState.keyFell = true;
    return { es: "Sacudes la rama con el palo... ¡la llave cae al suelo!", en: "You shake the branch with the pole... the key falls to the ground!" };
  },
  "key>door": () => {
    gameState.doorOpen = true;
    gameState.inventory = gameState.inventory.filter((i) => i !== "key");
    return { es: "La llave gira con un clic. La puerta se abre.", en: "The key turns with a click. The door opens." };
  },
};
// #endregion

scene("game", () => {
  // #region state
  // Everything that changes while you play fits in one object: that makes saving easy.
  gameState = { room: "garden", inventory: [], taken: [], keyInTree: true, keyFell: false, doorOpen: false };
  let [verb, selected] = ["look", null]; // selected: the inventory item you are using
  const value = (x) => (typeof x === "function" ? x() : x); // fixed text or text that depends on the state
  const findObject = (id) => Object.values(ROOMS).flatMap((r) => r.objects).find((o) => o.id === id);
  // #endregion

  // #region drawing
  // Each object is an invisible rectangle that receives clicks (the "hotspot")
  // plus a few simple shapes that draw it.
  function draw(o, open) {
    const c = rgb(...(o.color ?? [140, 100, 70]));
    const d = (comps) => add([...comps, "room"]);
    const { x, y, w, h } = o;
    if (o.shape === "tree") {
      d([rect(26, h - 90), pos(x + w / 2 - 13, y + 90), color(110, 75, 45)]);
      d([circle(68), pos(x + w / 2, y + 70), color(60, 130, 70)]);
      if (gameState.keyInTree) d([rect(18, 8), pos(x + w - 40, y + 40), color(250, 210, 70)]);
    } else if (o.shape === "door") {
      d([rect(w + 140, h + 20), pos(x - 70, y - 20), color(190, 180, 170)]); // house wall
      d([rect(w, h), pos(x, y), color(open ? rgb(20, 15, 25) : rgb(120, 70, 40))]);
      if (!open) d([circle(5), pos(x + w - 14, y + h / 2), color(240, 200, 80)]);
    } else if (o.shape === "arrow") {
      const p = o.dir > 0 ? [vec2(0, 0), vec2(w, h / 2), vec2(0, h)] : [vec2(w, 0), vec2(0, h / 2), vec2(w, h)];
      d([polygon(p), pos(x, y), color(255, 255, 255), opacity(0.6)]);
    } else if (o.shape === "pole") {
      d([rect(8, h), pos(x + w / 2 - 4, y), color(c)]);
    } else if (o.shape === "key") {
      d([circle(7), pos(x + 7, y + 8), color(c)]);
      d([rect(w - 10, 5), pos(x + 10, y + 6), color(c)]);
    } else if (o.shape === "notebook") {
      d([rect(w, h), pos(x, y), color(c)]);
      d([rect(6, h), pos(x, y), color(40, 70, 120)]); // spine
    } else {
      d([rect(w, h), pos(x, y), color(c), outline(2, rgb(0, 0, 0))]);
    }
  }

  function buildRoom() {
    destroyAll("room");
    const room = ROOMS[gameState.room];
    add([rect(640, FLOOR_Y), color(...room.sky), "room"]);
    add([rect(640, 270 - FLOOR_Y), pos(0, FLOOR_Y), color(...room.floor), "room"]);
    add([text(t(room.name), { size: 14, font: FONT }), pos(628, 60), anchor("topright"), opacity(0.8), "room"]);
    for (const o of room.objects) {
      if ((o.visible && !o.visible()) || gameState.taken.includes(o.id)) continue;
      draw(o, o.locked && !o.locked()); // fill: false → invisible, but its area still receives clicks
      add([rect(o.w, o.h, { fill: false }), pos(o.x, o.y), area(), "room", "hotspot", { data: o }]);
    }
  }
  // #endregion

  // #region interface
  // Bottom: the verbs (look, take) and the inventory. Top: the narration.
  add([rect(640, 90), pos(0, 270), color(30, 28, 50), z(5)]);
  add([rect(624, 44), pos(8, 8), color(0, 0, 0), opacity(0.6), z(5)]);
  const message = add([text("", { size: 15, font: FONT, width: 604 }), pos(18, 14), z(6)]);
  const statusLine = add([text("", { size: 14, font: FONT }), pos(320, 252), anchor("center"), z(6), color(255, 240, 180)]);
  const say = (txt) => (message.text = t(txt));

  const VERBS = [{ id: "look", name: { es: "Mirar", en: "Look" } }, { id: "take", name: { es: "Tomar", en: "Take" } }];
  VERBS.forEach((v, i) => {
    const b = add([rect(96, 32, { radius: 6 }), pos(14, 280 + i * 38), color(70, 70, 120), area(), z(6), "verb", { verb: v.id }]);
    b.add([text(t(v.name), { size: 16, font: FONT }), pos(48, 16), anchor("center")]);
  });

  function drawInventory() {
    destroyAll("slot");
    gameState.inventory.forEach((id, i) => {
      const o = findObject(id);
      const s = add([rect(110, 60, { radius: 6 }), pos(130 + i * 120, 285), color(selected === id ? rgb(120, 110, 40) : rgb(55, 52, 85)), area(), z(6), "slot", { item: id }]);
      s.add([circle(12), pos(55, 22), color(...o.color)]);
      s.add([text(t(o.name), { size: 13, font: FONT }), pos(55, 46), anchor("center")]);
    });
  }
  // #endregion

  // #region actions
  // A single click handler decides what you touched: a verb, the inventory
  // or an object in the scene (the topmost one if they overlap).
  function interact(o) {
    if (selected) {
      const combo = COMBOS[`${selected}>${o.id}`];
      say(combo ? combo() : { es: "Eso no sirve ahí.", en: "That doesn't work there." });
      selected = null;
    } else if (o.exit && !(o.locked && o.locked())) {
      gameState.room = o.exit;
      say(ROOMS[o.exit].enter);
    } else if (verb === "take" && o.take) {
      gameState.taken.push(o.id);
      if (o.id === "notebook") return go("end");
      gameState.inventory.push(o.id);
      say({ es: `Tomas: ${t(o.name)}.`, en: `You take: ${t(o.name)}.` });
    } else if (verb === "take") {
      say(o.noTake ?? { es: "No puedo llevarme eso.", en: "I can't take that." });
    } else {
      say(value(o.look) ?? o.name);
    }
    buildRoom();
    drawInventory();
  }

  onClick(() => {
    const verbButton = get("verb").find((b) => b.isHovering());
    const slot = get("slot").find((s) => s.isHovering());
    const hotspot = get("hotspot").reverse().find((h) => h.isHovering());
    if (verbButton) [verb, selected] = [verbButton.verb, null];
    else if (slot) selected = selected === slot.item ? null : slot.item;
    else if (hotspot) return interact(hotspot.data);
    else selected = null;
    drawInventory();
  });

  // The status line says what a click would do: "Use long pole with tree".
  onUpdate(() => {
    const hotspot = get("hotspot").reverse().find((h) => h.isHovering());
    for (const b of get("verb")) b.color = b.verb === verb && !selected ? rgb(110, 110, 190) : rgb(70, 70, 120);
    const name = hotspot ? t(hotspot.data.name) : "";
    if (selected) statusLine.text = t({ es: "Usar ", en: "Use " }) + t(findObject(selected).name) + t({ es: " con ", en: " with " }) + name;
    else statusLine.text = hotspot?.data.exit ? name : `${t(VERBS.find((v) => v.id === verb).name)} ${name}`;
    setCursor(hotspot || get("verb").concat(get("slot")).some((b) => b.isHovering()) ? "pointer" : "default");
  });

  say({ es: "La inventora dejó su cuaderno en el estudio, y la puerta está cerrada. ¿Dónde estará la llave?", en: "The inventor left her notebook in the study, and the door is locked. Where could the key be?" });
  buildRoom();
  drawInventory();
  // #endregion
});

// #region scenes
function screen(title, subtitle, next) {
  add([text(title, { size: 34, font: FONT, align: "center" }), pos(center().sub(0, 40)), anchor("center")]);
  add([text(subtitle, { size: 16, font: FONT, align: "center", width: 560 }), pos(center().add(0, 30)), anchor("center"), color(200, 210, 255)]);
  onButtonPress("start", next);
  onClick(next);
}

scene("title", () => screen(t({ es: "La casa de la inventora", en: "The inventor's house" }),
  t({
    es: "Elige un verbo (Mirar o Tomar) y haz clic en las cosas. Para usar algo del inventario, tócalo y luego toca dónde quieres usarlo.\nHaz clic o pulsa espacio para empezar.",
    en: "Pick a verb (Look or Take) and click on things. To use something from your inventory, tap it and then tap where you want to use it.\nClick or press space to start.",
  }),
  () => go("game")));

scene("end", () => screen(t({ es: "¡Encontraste el cuaderno!", en: "You found the notebook!" }),
  t({ es: "Ahora se puede terminar el invento. Haz clic o pulsa espacio para jugar otra vez.", en: "Now the invention can be finished. Click or press space to play again." }),
  () => go("game")));

go("title");
// #endregion

Design challenges

  • Making puzzles follow the logic of the world rather than a designer's whim; if the solution is absurd, the player ends up trying everything on everything.
  • Avoiding dead ends, such as losing an essential item or dying without warning, so the player explores without fear.
  • Answering every failed attempt with useful text, so that getting it wrong also gives clues.
  • Pacing places and items so the player does not get lost among too many options at once.
  • Adventures have a lot of text. If the text is kept apart from the code from the start, translating them is much easier.

History

  1. 1976
    OriginColossal Cave Adventure

    Will Crowther wrote it in 1975 and 1976, and Don Woods expanded it in 1977. The genre takes its name from this game.

    Colossal Cave Adventure (Wikipedia)
  2. 1977
    Zork

    Zork, made at MIT, understands full sentences instead of two-word commands. From 1980 it is sold split into three parts for microcomputers.

    Zork (Wikipedia)
  3. 1980
    Mystery House

    Mystery House, by Roberta and Ken Williams, is the first known graphical adventure, with a drawing for each place.

    Mystery House (Wikipedia)
  4. 1984
    King's Quest: Quest for the Crown

    King's Quest puts on screen an animated character that you walk among the scenery. It is Sierra's first game with a protagonist seen in the third person.

    King's Quest I (Wikipedia)
  5. 1987
    Maniac Mansion

    Maniac Mansion swaps typing for verbs and clicks, using the SCUMM engine, and sets Lucasfilm's point-and-click model.

    Maniac Mansion (Wikipedia)
  6. 1990
    The Secret of Monkey Island

    The Secret of Monkey Island makes dying almost impossible, so the game focuses on exploration and humour.

    The Secret of Monkey Island (Wikipedia)
  7. 1993
    Myst

    Myst, explored in the first person over pre-rendered images, is the best-selling PC game for nearly a decade and drives the adoption of CD-ROM.

    Myst (Wikipedia)
  8. 2003
    Samorost

    Samorost, which Jakub Dvorský made as a student, is released for free on Amanita Design's website. It shows a short, wordless adventure played with clicks alone.

    Samorost (Wikipedia)
  9. 2012
    The Walking Dead: Season One

    Telltale's The Walking Dead pushes puzzles into the background and puts dialogue choices at the centre, with consequences that carry over between episodes.

    The Walking Dead (Wikipedia)

Reference games

1983

The Portopia Serial Murder Case

Yuji Horii (Enix). NEC PC-6001, Famicom

You investigate a murder in Kobe with your assistant Yasu, questioning suspects and visiting places. In the Famicom version (1985) the text parser was replaced by a menu of commands, the template for Japanese adventures and the visual novel.

1984

King's Quest: Quest for the Crown

Sierra On-Line (Roberta Williams). IBM PCjr, MS-DOS, Apple II, Amiga, Atari ST, Master System

You guide Sir Graham through the fairy-tale kingdom of Daventry in search of three magical items. You walk him with the keyboard across animated screens, in front of and behind the scenery, and type commands to act.

1987

Maniac Mansion

Lucasfilm Games (Ron Gilbert y Gary Winnick). Commodore 64, Apple II, MS-DOS, NES

You control a group of teenagers who break into a mad scientist's mansion to rescue a friend. Instead of typing, you pick a verb from a menu and click on an object, using the SCUMM engine.

1990

The Secret of Monkey Island

Lucasfilm Games (Ron Gilbert, Tim Schafer y Dave Grossman). Amiga, MS-DOS, Atari ST, Macintosh

Guybrush Threepwood wants to become a pirate in an absurdly funny Caribbean. You solve inventory puzzles and insult sword fights with verbs and clicks, and you almost never die, so the game invites fearless exploration.

1993

Myst

Cyan (Rand y Robyn Miller). Macintosh, Windows

You arrive on a deserted island through a magic book and explore it by clicking on pre-rendered images. There is no inventory and no enemies; the challenge is to observe the world and work out how its machines operate.

2001

Phoenix Wright: Ace Attorney

Capcom (Shu Takumi). Game Boy Advance, Nintendo DS, Windows, iOS, Android

You are a rookie defence attorney. You alternate between investigations, where you gather evidence, and trials, where you cross-examine witnesses and point out where their testimony contradicts a piece of evidence.

2003

Samorost

Amanita Design (Jakub Dvorský). navegador, Windows, macOS, iOS, Android

A tiny gnome must stop a huge spaceship from crashing into his home planet. There is no text and no visible inventory; you click around the scenery until you find what reacts and in which order.

2012

The Walking Dead: Season One

Telltale Games. Windows, macOS, PlayStation 3, Xbox 360, iOS

Lee Everett protects a young girl, Clementine, during a zombie apocalypse. There are few puzzles; the core is dialogue choices, sometimes timed, whose consequences carry over from one episode to the next.

2025

Cheese is the Reason

Studio Laaya. navegador, Windows

A short point-and-click adventure made in 72 hours for Ludum Dare 57. You go down into the depths of Rat City to unclog a customer's pipe, talking to characters and using items.

Exercise

Play the demo and draw on paper which item unlocks what (the puzzle's dependency graph). Then add a new puzzle with one more item and write its text in Spanish and in an Indigenous language you know. A short adventure is a good way to tell a story about the place where you live.

Subgenres

  • Point-and-click adventureAn adventure where you use the mouse or your finger to point at places, objects and characters in the scene to move, examine, talk and use what is in your inventory. The challenge is combining items and dialogue clues to solve the puzzles that move the story forward.
  • Text-based gameA game presented in text alone, with no images. You read what happens and respond by typing commands or picking options, so the world and the challenge live mostly in your imagination.
  • Walking simulatorAn adventure where you wander a place at your own pace, usually in first person, and uncover a story through what you see, read and hear. There is no combat and no way to lose; the interest lies in exploring and interpreting.

Sources