Documentation menu

Tutorials

Dialogue player

OSF is headless: it describes the story, and your game decides how to show it. In this tutorial you build a dialogue player that runs in the terminal. It is an example implementation, written in JavaScript so it fits on one page, and every step maps directly to C#, GDScript or C++.

It plays the build/ folder from Your first project. A Unity (C#) version of this tutorial is [Unity tutorial TBD].

Workflow

  • Write the story in .osf and run osf build.
  • Your game loads the built resources and indexes them by Kind.id, the same form references use (Character.guard, Dialogue.intro).
  • The player walks the nodes, from the dialogue’s startNode through each next, until a node or option has none.

How a dialogue branches

Every dialogue is made of nodes and options, connected by next:

  • Story to Dialogue: the Story’s start is the first dialogue.
  • Dialogue to Node: the dialogue’s startNode.
  • Node to Node, or Option to Node: next with a node id.
  • Node or Option to another Dialogue: next: Dialogue.<id>, which starts at that dialogue’s startNode.
  • No next: the dialogue ends.

Variables add state. Each starts at its default; a node’s set is applied when the node starts, an option’s set when it is chosen, and an option is shown only when every value in its requires matches.

The player

The player reads YAML, so it needs one package: npm install yaml.

player.mjs
// player.mjs: plays an OSF story in the terminal.
// Usage: node player.mjs <build folder>
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { parse } from 'yaml'

// 1. Load every resource and index it by "Kind.id".
function load(dir, out = {}) {
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const path = join(dir, entry.name)
    if (entry.isDirectory()) load(path, out)
    else if (entry.name.endsWith('.yaml')) {
      const { kind, data } = parse(readFileSync(path, 'utf8'))
      if (data.id) out[`${kind}.${data.id}`] = data
    }
  }
  return out
}

const resources = load(process.argv[2] ?? 'build')
const story = Object.entries(resources).find(([key]) => key.startsWith('Story.'))[1]

// 2. Story state: every Variable starts at its default.
const state = {}
for (const [key, data] of Object.entries(resources)) {
  if (key.startsWith('Variable.')) state[data.id] = data.default
}

// 3. Fill in {{ Kind.id.field }} references. The field defaults to name;
//    variables are read from the current state.
const fill = (text) =>
  text.replace(/(?<!\\)\{\{\s*(\w+)\.([\w-]+)(?:\.(\w+))?\s*\}\}/g, (_, kind, id, field = 'name') =>
    kind === 'Variable' ? String(state[id]) : resources[`${kind}.${id}`][field],
  ).replace(/\\\{\{/g, '{{')

const matches = (values = {}) => Object.entries(values).every(([id, v]) => state[id] === v)

// 4. Walk the nodes.
const rl = createInterface({ input: process.stdin, output: process.stdout })
let dialogue = resources[story.start]
let nodeId = dialogue.startNode

while (nodeId) {
  const node = dialogue.nodes[nodeId]
  Object.assign(state, node.set)

  const { speaker, title, text } = node.content
  const name = title ?? (speaker ? resources[speaker].name : null)
  const line = fill(text).trimEnd()
  console.log(name ? `${fill(name)}: ${line}` : line)

  let next = node.next
  const options = Object.values(node.options ?? {}).filter((o) => matches(o.requires))
  if (options.length) {
    options.forEach((o, i) => console.log(`  ${i + 1}. ${fill(o.content.text)}`))
    const choice = options[Number(await rl.question('> ')) - 1] ?? options[0]
    Object.assign(state, choice.set)
    next = choice.next
  }

  // next is a node in this dialogue, Dialogue.<id> for another dialogue, or missing (the end).
  if (next?.startsWith('Dialogue.')) {
    dialogue = resources[next]
    nodeId = dialogue.startNode
  } else {
    nodeId = next
  }
}
rl.close()

Load the resources

Step 1 reads every .yaml file in the build folder and indexes its data by Kind.id. Because references use the same form, resources[node.content.speaker] is the speaker’s Character and resources[story.start] is the first Dialogue.

Fill in text references

Text can contain references such as {{ Character.guard }}. Step 3 replaces them with the character’s name, or a variable’s current value.

Walk the nodes

Step 4 is the whole player loop: apply the node’s set, print the speaker and text, show the options whose requires match, apply the chosen option’s set, and follow next.

Run it

$ node player.mjs my-story/build
Guard: Halt! Who goes there?
  1. A friend.
  2. Nobody. Bye.
> 1
Guard: Then take this key, Guard trusts you.

From here, a real game replaces console.log with its dialogue UI, and picks the language from the Locale resources.