15 min read

Why Open Quest Format keeps dialogue on its own rig, apart from the quests

In Open Quest Format, conversations run on their own model. A quest step points at a dialogue by reference and only receives the flags and events the dialogue drops on the wire, which is what lets Ink and Yarn Spinner jack in.

In the reference quest for Open Quest Format, an otter has scavved the harbor ledger and wants five silver koi before he hands it back, which makes him the pettiest fixer on the waterfront. I’m going to use that gig to show how quests and dialogue talk to each other in OQF, choom, because on the quest side the whole run is a single step. Here it is, pulled straight out of 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 jack in and read the complete cell, all the step asks for is five rare fish collected and a flag called otter_agreed. The step has no intel on what the otter says, whether he haggles like a street vendor in Kabuki, or that one of his options stays greyed out until you have the fish on you, and none of that is the step’s gig. The oqf:otter_trade cell is only a pointer, a fixer’s number scrawled on a napkin, made of a provider (oqf) and a conversation id (otter_trade), and the conversation itself lives in a different file, harbormasters-ledger.oqd, like a merc who keeps his real business in a separate safehouse:

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’s chromed with 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 the wire for flag.otter_agreed, like a netrunner on overwatch, so as soon as the dialogue writes that flag, the step completes on its own, without the dialogue ever having to know the step exists. The only thing the two files share is names, the way two mercs who only ever met through a fixer know each other by handle and nothing else: the quest knows the id of the conversation, and the conversation knows which flags to set.

What the quest runtime scavs from a conversation

The bridge between the two sides is laid out in five lines in docs/04-dialogue.md, which is about as lean as street chrome gets. 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 with the chrome 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. It sits on the wire like a netrunner reading packet headers, taking in only the events that come out of the conversation and never the script behind them.

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 hands the provider the gear it needs for the run, like a fixer kitting out a merc before a gig: 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 already been burned and which the game stashes in the save alongside everything else, the way any careful merc keeps receipts.

A talk objective sits on the street waiting 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 flatlined on, and that is what lets a binding written as conversation#node target one specific exit rather than any ending. The smuggler step runs exactly that play with talk smuggler smuggler_deal#accept, so it only completes if the player shakes on the smuggler’s gig. If the player deltas out instead, the conversation still ends, but it ends at walk_away, and the step sits right where it was, with no eddies paid out.

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 stays out of the quest model’s chrome

The main reason is that I didn’t want to write a whole 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 stitched together by a back-alley ripperdoc, and the quest half would need fresh surgery every time the dialogue half gained a feature. Keeping them apart means the quest spec stays small, with no extra chrome to haul around on every gig, and a crew whose writers already work in Ink or Yarn Spinner can keep them jacked into the tool they know instead of dragging every one of them onto an unfamiliar deck.

The second reason has to do with lore. Every RPG has a “tell me about the lighthouse” branch that exists purely for flavour, the kind of barroom chatter you’d hear from a merc nursing a drink at the Afterlife, and it should never move a single eddie of 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 the way ICE guards a corpo subnet. 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, so no scav gets to smuggle contraband through the lore branches without getting caught.

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, which is a merc on the payroll with no gig to run. I wanted this check because the binding between the two files is just a string, choom, and nothing else in the pipeline would catch a typo in it before that typo quietly flatlines a quest.

The stock provider, no ripperdoc required

If your game doesn’t already run a dialogue system, OqfDialogueProvider runs the OQF dialogue model directly, so you don’t have to burn eddies on somebody else’s chrome:

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, locked down like Arasaka firmware, 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 lights up the moment the fifth koi lands, without the conversation having to reboot. There is also a MAX_ADVANCES limit of 10000, a killswitch wired in because a linear loop with no choices in it would otherwise keep advancing forever, like a gonk stuck in a braindance loop with nobody around to pull the plug.

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, a fixer deal drawn as a graph. Every choice is an edge, and asking what the smuggler wants with the ledger routes you straight back to the offer on the table.

Smuggling dialogue across to Ink and Yarn

The mapping between OQF and the two formats is short, more a fixer’s cheat sheet than a corpo manual. A conversation becomes an Ink knot, or in Yarn a crew of nodes that share an oqf_conversation= tag. once maps to * versus + in Ink and to a <<once>> block in Yarn, so a one-shot choice stays burned on either rig. 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, the same merc working under two street handles.

Here is the otter’s handover option after a pass through toInk, choom:

* {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 coming out of the other ripperdoc’s chair, 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, because a claim with no receipts is just corpo marketing, so I ran the whole reference file through both formats and back on my own rig, 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 across the border with zero warnings and are deep-equal to the parsed model, and running toOqd on either result reproduces the original file byte for byte, which is about as preem as a round trip gets. The package’s own round-trip tests run the same check 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 at the border crossing, 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 merc with a fresh haircut and no chrome missing, but if you had written those defaults out by hand like a nervous rookie on his first gig, the JSON will look slightly different from what you started with.

Jacking exported dialogue into the engine’s chrome

The design doc calls the export lossless, choom, and that holds when the files go back through OQF’s own importers. Running the exported files inside the real engines is a different run entirely and the next gig on the board, and there are two constructs that need a little extra chrome bolted on over 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 a glitch, but for now a host that wants to run the file unchanged has to expand them into a chain of == comparisons by hand, like a netrunner cracking ICE one layer at a time, and engine-ready output for in is incoming, choom. fromYarn also accepts the expanded chain and reads it back as an or of comparisons, so a file you expanded by hand can still jack back in through the importer.

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, like a bouncer who never mentions the guests he turned away at the door, 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 out of nowhere once the fifth koi lands, instead of sitting visible and greyed out until then. Greyed-out options under Ink are on the way, already a gig on the fixer’s list. 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 gig from importing our own exports, and the package is upfront about what it carries across and what it reports, with none of the corpo spin. To see how it behaves when it hits code written out on the street, 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, a fixer’s number that rings through to dead air. The first line comes out as The ledger? reading., with a double space where the sequence used to be, like the empty socket a ripperdoc leaves behind after pulling out a piece of chrome. The second line becomes a separate node, offer__1, because an OQF node holds exactly one line, one merc per job. The flag assignment, on the other hand, makes it through the run intact.

I ran the same kind of stress gig on Yarn. The importer skips <<declare>>, scavs 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, a merc caught flying the wrong colours. 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 walk away with a usable result and a hit list of what to look at, instead of a scav job done in the dark. The importers only throw when the structure itself is fried past anything a ripperdoc could patch, for example an unterminated block comment, a Yarn node missing its ===, or a <<once>> with no <<endonce>>.

How Yarn Spinner jacks in today

A Yarn provider for @oqf/dialogue is incoming, and it will slot into the rig right 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’re running, and there is no maintained JavaScript runtime with a stable API that the package could wrap, so there’s no chrome on the market worth installing. For now, Yarn runs on the engine side of the wall and talks to the bridge from over 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 of netrunner work: 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 where a gonk gets burned 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, so the quest flatlines quietly and keeps smiling.

Ink got a real provider because inkjs exists and there was real chrome to wrap. It is an optional peer dependency, and adapter.ts never imports it, not even for types, so a project that doesn’t run Ink never gets that chrome welded onto its rig. Instead, the adapter jacks into 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 from jack-in to delta.

Incoming, choom. Everything described here is version 0.1.0, and so far the bridge has only been jacked into a test harness, never a live rig. The next pieces on the fixer’s board 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, open to any netrunner who wants to jack in and read it.

Let's link up, choom.

Always down to trade notes, talk shop, or just ping. The net is the fastest way to reach me.

Ping me