Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Dialog

A conversation is what an NPC says to the one player who talks to it, and the choices that player picks among. dialog() builds it as a tree of nodes: each node has a line, and either choices that lead to other nodes or an end. <Dialog tree={...} /> inside an Npc gives the NPC its conversation, and a press of its Interact prompt starts it. The DialogPanel in the game’s HUD shows it. The MCP’s add_dialog, or spawnite add dialog <npc>, gives an NPC that exists a short conversation to start from, in the template.

It is one of three layers of NPC speech:

  • A bark is a line over the NPC’s head that everyone nearby sees, which a say step of its routines draws over its name.
  • A conversation is a panel for the one player who is talking, so two players each hold their own with the same NPC. A player holds one conversation at a time.
  • A consequence is what a choice does, which a room runs on the server.
import {
addItem,
countItem,
Dialog,
dialog,
Interact,
Npc,
} from "@spawnite/engine";
const talk = dialog("mira", { gift: "potion", spareBelow: 2 })
.node("hello", (n) =>
n
.say("Morning, {player}! I'm {npc}.")
.choice("Could you spare a potion?", "gift", {
when: ({ player, values }) =>
countItem(player, values.gift) < values.spareBelow,
})
.choice("Just passing by.", "bye"),
)
.node("gift", (n) =>
n
.do(({ player, values }) =>
addItem(player, { item: values.gift, count: 1 }),
)
.say("Here, take a {gift}.")
.choice("Thank you!", "bye"),
)
.node("bye", (n) => n.say("Safe roads, {player}.").end())
.start("hello");
export function Mira() {
return (
<Npc name="Mira" title="Herbalist">
<Interact prompt="Talk" />
<Dialog tree={talk} />
</Npc>
);
}

Mount one DialogPanel in the game’s Hud, as a Bag is:

import { DialogPanel, Hud } from "@spawnite/engine";
export function GameHud() {
return (
<Hud>
<DialogPanel />
</Hud>
);
}

dialog("mira", { gift: "potion", spareBelow: 2 }) names the dialog and declares each value once; a dialog with no values takes its name alone, dialog("guard"). A line reads one as {gift}, and a when or a do reads the same one as values.gift, so a number is written in one place. The engine adds two values of its own: {player}, the talking player’s name, or “traveller” for a player with none, and {npc}, the NPC’s name. A value may be a function of the conversation, { coins: ({ player }) => readCoins(player) }, which a line reads each time it shows. A name holds letters, digits and underscores, such as herb_name: dialog() refuses any other name with a type error, and again as it runs, naming the value.

The names are typed: a {name} in a line that the dialog lacks is a type error, and the builder also refuses it as it runs, naming the node and the value. A choice’s target and start are checked the same way: start reads which target names no node. A target held in a plain string variable is checked only as the builder runs. Choice text shows as written, with no values filled in. A node holds at most nine choices, one for each number key: the builder refuses a node with more, naming the node and its count, so split a longer list across two nodes with a choice that leads on to the rest.

A line stays plain data, so the wiki, the devtools and a later translation file can list every line.

Each is a function that receives the conversation: world, the player’s entity, the npc’s entity and the values.

  • when on a choice hides it while it returns false. It is read as the node begins, and again as the player’s pick arrives: a pick of a choice whose when has turned false since is dropped, and the conversation stays on its node.
  • do on a node runs once each time the node begins, before its line is filled in, so a line can read what it changed.

Both run only where the world runs its rules: in a room, on the server. The record a page receives carries the filled line and the choices that are shown, so a page evaluates nothing, and it reaches the talking player’s page alone.

A shop node charges with spendCoins and hands over with addItem. Give the item first: addItem returns the count the bag took, 0 for a full bag, so the node spends only once the potion is in. spendCoins returns false where the wallet no longer holds the price, and the node then takes the potion back out with removeItem, so it sells whole or not at all. The choice’s when hides the sale from a player short of the price, and the room reads it again as her pick lands, so a player who spent her coins since the node began buys nothing. A do runs on the room, so requireAuthority(world) gives the authority spendCoins takes.

packages/room/test/outside/shop.ts
import {
addItem,
dialog,
readCoins,
removeItem,
requireAuthority,
spendCoins,
} from "@spawnite/engine/core";
export const shop = dialog("tobin", { item: "potion", price: 5 })
.node("hello", (n) =>
n
.say("A {item} for {price} coins?")
.choice("I'll take one.", "sold", {
when: ({ player, values }) => readCoins(player) >= values.price,
})
.choice("Not today.", "bye"),
)
.node("sold", (n) =>
n
.do(({ world, player, values }) => {
// Into the bag first: a full bag takes none, and keeps
// its coins. Where the wallet no longer holds the price,
// the potion comes back out, so the node sells whole or
// not at all even without the choice's `when`.
const potion = { item: values.item, count: 1 };
if (addItem(player, potion) === 0) return;
if (!spendCoins(requireAuthority(world), player, values.price))
removeItem(player, potion);
})
.say("Mind your bag: what it can't hold, I keep.")
.choice("Thanks.", "bye"),
)
.node("bye", (n) => n.say("Come back soon, {player}.").end())
.start("hello");

DialogPanel stands along the bottom of the screen: the speaker’s name and title, a portrait, the line typed out, and the choices as buttons below the pane. It fits a phone’s width.

  • The number keys 1 to 9, or a tap, pick a choice. The numbers count the choices that are shown.
  • The Interact key shows the whole line while it types. Once the line is whole, it goes on where a node has one way on: past a node that ends, or through its only choice. A held key acts once, on its first press.
  • Escape ends the conversation and leaves the menu shut, as World of Warcraft’s Escape closes a gossip window first; the next Escape opens the menu. The menu opening another way ends the conversation too.
  • The panel frees the cursor while the conversation is open, so a click picks a choice under a camera that captures the cursor, and the camera takes the cursor back when it ends, as the camera page says. The keys keep working with the cursor free.
  • Under reduced motion the line shows whole at once.

Its portrait prop draws the speaker’s face from the speaker’s name, such as portrait={(speaker) => <img src={faces[speaker]} alt="" />}. Left out, the pane shows the first letter of the name.

The player moves freely. A conversation holds out to the NPC’s Interact radius plus the one metre a room allows a choice, so a choice the room accepted never ends in the same step, as World of Warcraft closes a gossip window farther out than it opens one. Walking past that ends the conversation, as a disconnect does. A press of another NPC’s prompt while one conversation is open starts nothing. The NPC stops, faces the player, clears the line over its head, and holds its routine where it is. The conversation adds the TalkingTrait trait to the NPC, which holds its routine alone: every other machine on it keeps running, and a MachinePausedTrait the game added stays the game’s. With two players talking to it, TalkingTrait stays until the last conversation ends. The routine then resumes at the step it was on: a walk heads back for the point it was walking to, and a wait keeps its seconds.

A conversation sits on the talking player’s character. Its conversation trait holds the NPC by the key a dump files it under, the speaker’s name and title, the node, its filled line, the choices that are shown, whether the Interact key ends it there, and entered, the count of nodes entered so far. Beside it sits the dialog’s machine, named for the name dialog() took, miraDialog for dialog("mira", ...), so every NPC handed one build runs one machine, as routines do: a node is a state, and a choice is the event choose1, choose2 and so on. spawnite play dump shows both. A hot reload that builds the dialog again ends a conversation on the old build, and the next press starts one on the new.

The room runs every conversation. A player’s pick reaches the room as the npcs.choose command, { npc, choice, entered }, where choice is a choice’s number, 0 for the Interact key past a node that ends, and -1 to end it, and entered is the record’s count the page showed. The room checks each message by its shape, at most 8 a second, the sender’s own conversation, and the sender’s reach, as it checks an Interact press. It takes the first pick a conversation gets in a step whose entered matches the record’s, so a double click or a stale pick lands on no node the player has not seen; an end is taken whatever its count. The room streams each record and its machine to its own player alone, as it does her bag, since the dialog’s machine declares ownerOnly: another player’s page reads nothing of the conversation, the node included, and another player sees the NPC stop and face the player it talks to, and sees its barks as before.

Roblox ships Dialog and DialogChoice objects, a tree with a bubble over the head and a list of choices. Unity, Unreal, Godot, PlayCanvas and Phaser games use Yarn Spinner, Ink or Dialogic, text scripts of nodes, choices, conditions and commands with {variable} substitution. World of Warcraft shows gossip menus with a condition on each option, and Old School RuneScape and Final Fantasy XIV a panel along the bottom with the speaker’s name and portrait. The engine takes the node-and-choice tree all of them share and the bottom panel, and writes the tree in TypeScript on the engine’s own machine, so a conversation dumps, streams and checkpoints like any other machine.

DialogPanelProps, DialogProps, Talk and ConversationTrait.