Appearance
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
onUsewithev.entity. ui.showDialoguesays 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 itscolor. Pass the speaker's entity and it speaks in the scene (itsSpeechcomponent: the whole party, and onlookers if it's public, see it talk; the words stay the player's). Addchoicesand the line is asked in the dialogue's plank box, the choices as its buttons; the answer arrives inonDialogueChoice.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.startTimertimers are kept by the host, so they survive restarts, and fire in the lobby that set them.entities.spawngives you an added type with its model, collider and health. A glTF boss is the same, withmodel: "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;
onEntityDamagecan refuse it), and an entity type'sairuns the base game's mob behaviour ("hostile","ranged","neutral","passive"). Here the boss has noaiand a tap is a hit too (content.damageEntityinonUse). The sample's knights and wraiths hunt withai: "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 itsdropslie on the ground. Read health withcontent.getEntity(for a boss bar).
Going further
- Dialogue trees:
ui.showDialogue(p, keeper, text, { choices: [...] }), thenonDialogueChoice(p, i). - Cutscenes:
players.playCameraPath(p, { keys: [...] })once their entry protection is over, orplayers.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.