Skip to content

Guide: a story dungeon ​

You'll build: a co-op dungeon for parties of up to 4, with an NPC who talks, a door that opens, a boss, and progress saved between visits. You'll use: a stashing runtime, a locked lobby per party, entity types, onUse, ui.showDialogue, timers, a player profile in storage. Status: Built (headless, and in the plots dev stack, whose page draws the dialogue, on the web and in the iOS app: the base game's NPC dialogue, the same the tutorial's Map Keeper speaks through). Recorded voice lines with word timings are Planned; a voice sound played as each line starts is Built.

The finished code is examples/dungeon. A full version, with fights, a glTF boss and cutscenes, is the sample plot/samples/story-dungeon ("The Old Keep" on its monument in the plots dev stack).

The shape ​

  • Stashed: players bring nothing in; progress is the plot's own data.
  • A lobby per party: a lobby of 4 is a room for one party, with its own copy of the crypt: its own door state, NPC and boss, over the same place. The platform fills it as players arrive; once the party sets off (the door opens), the lobby is locked, so nobody walks into the middle of their story. No countdown: there's no round to start, just a room. Outsiders watching see the featured party's progress.
  • Entry protection is a feature: the 10 s at the entrance is when the title card shows.
json
{
  "kind": "runtime", "namespace": "crypt", "id": "ana.crypt", "version": "1.0.0", "name": "The Crypt",
  "apiVersion": 1, "code": "dist/logic.js", "stashItems": true,
  "lobbies": { "size": 4, "area": { "min": [0, 0, 0], "max": [8, 6, 8] } },
  "rating": "everyone", "tags": ["story", "adventure", "co-op"]
}

An NPC per party ​

ts
// No base mob AI runs in plots: a plot moves and fights with its entities itself. This boss just stands guard.
const SKELETON = entities.add("skeleton", { model: "inventery:skeleton", health: 10 });

const game = defineLobbyGame<State>({
  setup: () => ({ keeper: 0, boss: 0, doorOpen: false, bossDown: false }),

  onLobbyLoad(s) {
    if (!s.keeper) {
      s.keeper = scene.create({
        Transform: { pos: [4, 1, 6] },
        Model: { asset: "inventery:villager" },
        Nametag: { text: "The Keeper", color: "gold" },
      });
    }
  },

onLobbyLoad runs when a lobby is created, and again when it's loaded after a restart or a suspend. The if (!s.keeper) makes it safe to run twice: the entity is kept by the host, and its id is in the lobby's saved state.

Saved progress ​

ts
  onPlayerEnter(s, p) {
    const chapter = storage.getPlayerProfile<number | null>(p, "chapter") ?? 1;
    ui.showTitle(p, "The Crypt", { subtitle: `Chapter ${chapter}`, color: "gold" });
  },

PlayerId is a per-plot id, stable across visits, so this is the player's own save. getPlayerProfile and setPlayerProfile are the whole plot's, so the save follows the player into any party. (getPlayer and setPlayer are per lobby: for what matters only in this party's run.)

Talking, a door, a boss ​

ts
  onUse(s, ev) {
    if (ev.entity === s.boss && !s.bossDown) {
      content.damageEntity(ev.entity, 2, { by: ev.by });  // a tap is a hit: hurting plot entities is the plot's
      return true;
    }
    if (ev.entity === s.keeper) {
      ui.showDialogue(ev.by, s.keeper, "The door opens for those who listen.", { name: "The Keeper", color: "gold" });
      if (!s.doorOpen) {
        s.doorOpen = true;
        lobbies.lock();                                   // the party is set: nobody new joins mid-story
        world.setBlock(DOOR, world.blockId("air"));       // this party's copy only
        time.startTimer(2, "boss");
      }
    }
    return true;
  },

  onTimer(s, name) {
    if (name === "boss") s.boss = entities.spawn(SKELETON, [12, 1, 4]);
  },

  onEntityDeath(s, ev) {
    if (ev.type !== SKELETON || s.bossDown) return;
    s.bossDown = true;
    for (const p of players.list()) {
      storage.setPlayerProfile(p, "chapter", 2);
      ui.showToast(p, { text: "Chapter 1 complete", icon: "star" });
    }
  },
  • Tapping an entity arrives as onUse with ev.entity.
  • ui.showDialogue says an NPC line through the base game's NPC dialogue, the tutorial's Map Keeper's: subtitles over play, a word at a time, with the speaker's name over them in its color. Pass the speaker's entity and it speaks in the scene (its Speech component: the whole party, and onlookers if it's public, see it talk; the words stay the player's). Add choices and the line is asked in the dialogue's plank box, the choices as its buttons; the answer arrives in onDialogueChoice. voice (a sound asset) plays as the line starts.
  • lobbies.lock() closes this party's lobby to newcomers; the next arrivals get a fresh lobby and their own crypt.
  • time.startTimer timers are kept by the host, so they survive restarts, and fire in the lobby that set them.
  • entities.spawn gives you an added type with its model, collider and health. A glTF boss is the same, with model: "models/boss.glb".
  • Fighting entities. A player's swing at an entity is the base melee (weapon damage, crits, knockback, half a second of invulnerability; onEntityDamage can refuse it), and an entity type's ai runs the base game's mob behaviour ("hostile", "ranged", "neutral", "passive"). Here the boss has no ai and a tap is a hit too (content.damageEntity in onUse). The sample's knights and wraiths hunt with ai: "hostile", and its Warden walks and strikes by the sample's own code (its phases follow its health). At 0 health an entity dies: onEntityDeath, and its drops lie on the ground. Read health with content.getEntity (for a boss bar).

Going further ​

  • Dialogue trees: ui.showDialogue(p, keeper, text, { choices: [...] }), then onDialogueChoice(p, i).
  • Cutscenes: players.playCameraPath(p, { keys: [...] }) once their entry protection is over, or players.setCamera(p, { mode: "fixed", pos, target }).
  • Interiors in the sky or underground: anywhere inside your bounds. There are no pocket dimensions, which is what keeps "what you see outside is what you walk into" true.
  • A garden instead of a dungeon: the same pattern with lobbies: null: everyone shares one world.