12 min read

Why Open Quest Format keeps dialogue separate from quests

In Open Quest Format, conversations live in their own model. A quest step points at a dialogue by reference and only receives the flags and events the dialogue sets, which is what makes it possible to plug in Ink and Yarn Spinner.

In the reference quest for Open Quest Format, an otter has taken the harbor ledger and wants five silver koi in exchange for it. I’m going to use that trade to show how quests and dialogue talk to each other in OQF, because on the quest side the whole thing is a single step. Here it is, straight from packages/examples/quests/harbormasters-ledger.oqf:

S	trade		step.find_otter		collect fish.rare 5 and flag.otter_agreed			1	oqf:otter_trade	1	2	Trade five silver koi for the ledger

If you look at the complete cell, all the step asks for is five rare fish collected and a flag called otter_agreed. The step doesn’t know what the otter says, whether he haggles, or that one of his options stays greyed out until you have the fish. The oqf:otter_trade cell is only a pointer, made of a provider (oqf) and a conversation id (otter_trade), and the conversation itself lives in a different file, harbormasters-ledger.oqd:

C	otter_trade	2	offer	The otter names his price
N	offer						The ledger? Fine reading. Five silver koi and it is yours, and no haggling.
O	handover	count.trade >= 5	od	otter_agreed;otter_traded		Here. Five silver koi, fresh off the line.
X	cozycoast	sfx	coins
O	curiosity					What does an otter want with a harbor ledger?
O	come_back					I do not have the fish yet.
N	handover					cozycoast.ledger.handed:from=otter	A deal is a deal. Mind the salt stains, those are mine.
N	curiosity			offer			Names of every boat that ever stiffed me, in one tidy column. I call that a library.
N	come_back						Then go fish. The tide is patient and so am I.

The option that matters here is handover. It has the flags od, meaning it can be picked once and is shown disabled while its condition fails, and its condition is count.trade >= 5. When the player picks it, the dialogue sets otter_agreed;otter_traded. The quest step was already watching flag.otter_agreed, so as soon as the dialogue writes that flag, the step completes on its own, without the dialogue having to know the step exists. The only thing the two files share is names: the quest knows the id of the conversation, and the conversation knows which flags to set.

What the quest runtime receives from a conversation

The bridge between the two sides is described in five lines in docs/04-dialogue.md. A provider starts a conversation at a ref, and while that conversation runs, the provider emits flag.set { flag, value }, dialogue.choice { node, choice }, any custom events the dialogue declares, and finally dialogue.ended { actor, node }. Both providers that ship also open with dialogue.started. That is the entire contract, which means the quest never sees a line of text, a speaker or a choice label, only the events that come out of the conversation.

In code, that contract is the DialogueProvider interface in packages/dialogue/src/bridge.ts. A provider has a name and a single start(ref, context) method, which returns a session with current(), advance() and choose(index). The context is how the game gives the provider what it needs: a resolve function so that conditions can read variables, an emit sink to send events into (usually the quest runtime’s own emit), and an optional OnceStore, which remembers which once-only choices have been taken and which the game saves alongside everything else.

A talk objective waits for dialogue.ended and matches on actor and on node, where node carries the conversation id. The payload also includes at, the node the conversation actually ended on, and that is what lets a binding written as conversation#node target one specific exit rather than any ending. The smuggler step uses this with talk smuggler smuggler_deal#accept, so it only completes if the player takes the deal. If the player walks away instead, the conversation still ends, but it ends at walk_away, and the step stays where it was.

Dialogue providers reach the quest runtime only through the bridge Three dialogue sources, the built-in OqfDialogueProvider, the InkProvider that wraps inkjs, and host glue around Yarn Spinner, all emit the same bridge events: dialogue.started, flag.set, dialogue.choice, custom events and dialogue.ended. The quest runtime consumes only those events. A quest step points at a conversation with a reference such as oqf:otter_trade. OqfDialogueProvider runs .oqd directly InkProvider wraps inkjs Host glue Yarn Spinner, in the engine Bridge events dialogue.started flag.set dialogue.choice custom events dialogue.ended Quest runtime talk, flag.* conditions oqf:otter_trade the step's only reference
Every dialogue source speaks the same handful of events. The quest runtime never sees a line, a speaker or a choice label, only the step's reference and what comes across the bridge.

Why dialogue is not part of the quest model

The main reason is that I didn’t want to write a dialogue system into a quest spec. If the quest model also had to describe lines, speakers, option guards and once-only choices, it would really be two specs glued together, and the quest half would have to change every time the dialogue half gained a feature. Keeping them apart means the quest spec stays small, and a team whose writers already work in Ink or Yarn Spinner can keep them in the tool they know instead of moving them somewhere new.

The second reason has to do with lore. Every RPG has a “tell me about the lighthouse” branch that exists purely for flavour and should never change the game state. In the full .oqd, three of Ines’s branches in ines_intro carry the l flag, which marks them as lore. Setting lore: true on a node is a promise that nothing in the subtree below it sets a flag or emits an event, and validateDialogue checks that promise for you. If a writer slips a flag in two nodes further down, the validator reports lore-node-effect, with the ids of the offending nodes in the message.

You can also pass your quests to the validator, as validateDialogue(docs, { strict, quests }), and it will then check the references from the quest side as well. A step that binds a conversation or node that doesn’t exist is reported as unknown-dialogue-ref, and in strict mode, a conversation that no step points at is reported as unbound-conversation. I wanted this check because the binding between the two files is just a string, and nothing else in the pipeline would catch a typo in it.

The built-in provider

If your game doesn’t already have a dialogue system, OqfDialogueProvider runs the OQF dialogue model directly:

import { OqfDialogueProvider, parseOqd } from '@oqf/dialogue'

const provider = new OqfDialogueProvider([parseOqd(text)])
const session = provider.start('ines_intro', {
  resolve: (path) => runtime.resolve(path),
  emit: (event) => runtime.emit(event),
  once: saveGame.onceStore,
})

Its behaviour is frozen in docs/13-dialogue-spec.md, and it works like this. When a node is shown, the provider applies the node’s set and then its emit, once per visit. When the player picks a choice, the provider applies that choice’s own set and emit, emits dialogue.choice, records once and moves on. Conditions go through resolve every time current() builds a view, which is why the otter’s handover option becomes available the moment the fifth koi lands, without the conversation having to restart. There is also a MAX_ADVANCES limit of 10000, because a linear loop with no choices in it would otherwise keep advancing forever.

The smuggler_deal conversation in the editor's dialogue view: the offer node with three choices leading to walk_away, curiosity and accept, with curiosity leading back to the offer
The smuggler's offer from the reference quest in the editor's dialogue view. Every choice is an edge, and asking what the smuggler wants with the ledger leads to a node that sends you back to the offer.

Converting dialogue to Ink and Yarn

The mapping between OQF and the two formats is short. A conversation becomes an Ink knot, or in Yarn a group of nodes that share an oqf_conversation= tag. once maps to * versus + in Ink and to a <<once>> block in Yarn. set becomes ~ flag__name = value in Ink or <<set $flag__name to value>> in Yarn, and emit becomes a call to an oqf_emit external in Ink or command in Yarn. Variable names follow the rules in packages/dialogue/src/ink/naming.ts, which replace dots with __, so flag.otter_agreed becomes flag__otter_agreed in Ink and $flag__otter_agreed in Yarn.

Here is the otter’s handover option after toInk:

* {count__trade >= 5} [Here. Five silver koi, fresh off the line.] # disabled
    # ext: x-cozycoast.sfx=coins
    ~ flag__otter_agreed = true
    ~ flag__otter_traded = true
    -> otter_trade.handover

And here is the same option after toYarn:

<<once>>
-> Here. Five silver koi, fresh off the line. <<if $count__trade >= 5>> #oqf_disabled
    <<oqf_ext "x-cozycoast.sfx=coins">>
    <<set $flag__otter_agreed to true>>
    <<set $flag__otter_traded to true>>
    <<jump otter_trade_handover>>
<<endonce>>

I didn’t want to call the conversion “lossless” without a number behind it, so I ran the whole reference file through both formats and back, using the built @oqf/dialogue package. harbormasters-ledger.oqd is 2,863 bytes and contains five conversations, 18 nodes and 13 choices. toInk turns it into 4,349 bytes of Ink, and toYarn turns it into 5,023 bytes of Yarn. Both fromInk(toInk(doc)) and fromYarn(toYarn(doc)) come back with zero warnings and are deep-equal to the parsed model, and running toOqd on either result reproduces the original file byte for byte. The package’s own round-trip tests check the same thing on a document built to cover every row of the mapping table, with a second pass that checks the exported text is stable.

A few spellings do get normalised along the way, because neither Ink nor Yarn can tell them apart from the default. value: true on a set comes back as an omitted value, and show: 'hidden', once: false, lore: false and empty arrays come back absent. The model you get back is the same, but if you had written those defaults out by hand, the JSON will look slightly different from what you started with.

Running exported dialogue inside the engine

The design doc calls the export lossless, and that holds when the files go back through OQF’s own importers. Running the exported files inside the real engines is the next stage, and there are two constructs that need a little extra handling there today.

The first one is in, the membership operator in OQF conditions. Neither Ink nor Yarn has an inline list literal, so the exporter writes ["spring", "summer"] ? var__season for Ink and $var__season in ["spring", "summer"] for Yarn. OQF’s importers read those back without complaint, but for now a host that wants to run the file unchanged has to expand them into a chain of == comparisons, and engine-ready output for in is incoming. fromYarn also accepts the expanded chain and reads it back as an or of comparisons, so a file you expanded by hand can still be imported.

The second one is show: disabled, the greyed-out option. The Ink export keeps it as a # disabled tag so that it survives the round trip. The problem is that Ink only hands over the choices whose guard passed, and the InkProvider in ink/adapter.ts currently reports every choice it receives as enabled. In practice, that means under Ink the otter’s handover option appears once the fifth koi lands, instead of being visible and greyed out until then. Greyed-out options under Ink are on the way. Yarn does report option availability to the game, so on the Yarn side #oqf_disabled is already enough for the UI to show the option correctly.

Importing hand-written files is a different job from importing our own exports, and the package is upfront about what it carries across and what it reports. To see how it behaves, I wrote a small Ink stitch:

=== otter ===
= offer
The ledger? {~Fine|Dull} reading.
Five koi and it is yours.
* [Deal.] -> haggle ->
    ~ flag__otter_agreed = true
    -> END
+ [Later.] -> END

fromInk returns a document along with three warnings: the shuffle sequence on line 3 was dropped, the tunnel call on line 5 was dropped, and the divert to haggle points at nothing. The first line comes out as The ledger? reading., with a double space where the sequence used to be. The second line becomes a separate node, offer__1, because an OQF node holds exactly one line. The flag assignment, on the other hand, makes it through intact.

I ran the same kind of test on Yarn. The importer skips <<declare>>, cuts the {$gold} interpolation out of the line, and reads <<set $gold to 5>> as a flag called gold, with a warning that $gold isn’t a flag.* variable. Every warning names a line and a construct, and oqf import prints them on stderr as <file>:<line>: <construct>: <message> while still writing the document, so you get a usable result together with a list of what to look at. The importers only throw when the structure itself is broken, for example an unterminated block comment, a Yarn node missing its ===, or a <<once>> with no <<endonce>>.

How Yarn Spinner connects today

A Yarn provider for @oqf/dialogue is incoming, and it will sit next to the Ink one. It isn’t there yet because Yarn Spinner’s runtime is written in C# and owned by whichever engine integration you use, and there is no maintained JavaScript runtime with a stable API that the package could wrap. So for now, Yarn runs on the engine side and talks to the bridge from there.

Until the provider lands, packages/dialogue/src/yarn/README.md describes the glue a host has to write, which comes to roughly thirty lines: forward changes to $flag__* variables as flag.set, register one oqf_emit command, and report when a conversation starts, when a choice is made and when it ends. The part that is easy to get wrong is the other direction. count.*, step.*, outcome.*, quest.* and var.* belong to the quest runtime, so the host has to push them into Yarn’s variables before a node reads them. If the host forgets, $count__trade reads as zero, and the otter’s option never shows up without any error being reported anywhere.

Ink got a real provider because inkjs exists and there was something to wrap. It is an optional peer dependency, and adapter.ts never imports it, not even for types, so a project that doesn’t use Ink doesn’t pull it in. Instead, the adapter talks to a small StoryLike interface that a compiled inkjs Story already satisfies, and the adapter tests compile the exported Ink with inkjs and walk through it.

Incoming. Everything described here is version 0.1.0, and so far the bridge has only been run against a test harness. The next pieces are the Yarn provider, engine-ready in, greyed-out options under Ink, and engine exporters for Godot, Unity and Unreal in v1.5, along with Cozy Coast as the first shipping game to use it, which is where the otter will haggle over koi in an actual build.

The source for everything in this post is on GitHub.

Let's connect.

Always happy to talk shop, compare notes, or just say hi. Email or LinkedIn is the fastest way to reach me.

Get in touch