14 min read

What Skyrim and Baldur's Gate 3 quests share under the chrome

A run through the Open Quest Format model, choom: how steps wire into a flat graph, how objectives count events, how payouts and endings hit the wire as events and flags, and why so much of the gig is left to the engine on purpose.

If you jack into the quest journal in Skyrim and then pull up the one in Baldur’s Gate 3, choom, the shapes look familiar. In both games you usually have several objectives running hot at once, some of them are tagged optional, there is more than one way through the middle of a gig, and the ending you pick gets remembered by the rest of the world afterwards, like street cred that follows you from bar to bar. The engines underneath share zero chrome, one corpo stack against another, and yet the quests are clearly bolted together from the same parts.

What I wanted with Open Quest Format was to write down that shared part, and only that part, so it could live outside any one megacorp’s engine. Today it exists as a published spec with a reference parser, validator and runtime in TypeScript, all fresh off the ripperdoc’s table at 0.1.0. Engine exporters are on the way, and Cozy Coast is lined up as the first shipping game to run it, the first rig to take this chrome for real. Since no game has shipped on it yet, there is no street footage to show, so this post is a run through the model itself, which is what docs/01-model.md defines.

All the examples below come from the nine single-pattern templates in packages/examples, plus the reference quest, The Harbormaster’s Ledger. The Ledger was written to put every v1 feature through one test run, the way a fixer hands a fresh merc a single job that touches every part of the trade, so it is a preem place to watch how the pieces fit together inside a single gig.

A quest is a flat stack of steps, no ICE inside ICE

The first call was to skip nesting entirely, so there are no subquests stacked inside subquests like ICE layered on ICE. A quest holds a list of steps, and the steps refer to each other by ID, so the structure of the quest lives in those references rather than in how the file is laid out on the deck. That also means the order some merc typed the steps in carries zero meaning for the runtime, which reads the wiring and ignores the seating chart.

Each step moves through a short ladder of states, the same way a gig moves from rumour on the street to a done deal:

locked -> available -> active -> done
                  \        \-> failed
                   \-> skipped

Three data-driven transitions move a step up and down that ladder. unlock takes it from locked to available, complete takes it from active to done, and fail flatlines it to failed. There is a fourth one, activate, which exists for the gap between “the fixer has posted the gig” and “the choom has actually taken the job”. Plenty of quests do not need that distinction, so when activate is absent the step goes live the second it unlocks, with no handshake at the Afterlife required.

Because steps only point at each other, several of them can run hot at the same time, and that covers the most common thing a quest log does. The parallel template is a festival: once you have talked to the organizer, who plays fixer for the night, three jobs open up together, and the last step waits until the whole crew has reported in. Trimmed down to the cells that matter, it looks like this:

gather_lanterns   unlock: step.meet_organizer   complete: collect lantern 4
hang_bunting      unlock: step.meet_organizer   complete: interact bunting_post
hire_band         unlock: step.meet_organizer   complete: talk bandleader band_hire
open_festival     unlock: step.gather_lanterns and step.hang_bunting and step.hire_band

The and in the last line is all it takes to pull the three branches back together, so there is no separate join chrome to install and no new ICE to learn. The same template also has a step, lanterns_halfway, that unlocks on count.gather_lanterns >= 2, which means it can trip off another step’s counter before that step is finished, like a fixer pinging you with a bonus while the run is still live. And there is bake_pies, a side gig that nothing else waits on.

Two runs, one drop point

The README describes branching as “kill the guard or pick the lock”. In the Ledger the choice is to trade or to steal. The otter under the boardwalk is sitting on the harbor ledger like a scav on a crate of chrome, and you can either pay him five silver koi, which is the eddies route, or ghost into his den at night and lift it like a street thief who knows the patrol schedule.

To give one step two routes, choom, you write two sibling steps and then a third step that unlocks on either of them, so both crews report to the same drop:

trade        unlock: step.find_otter   complete: collect fish.rare 5 and flag.otter_agreed
steal        unlock: step.find_otter   complete: interact ledger_chest and var.time.isNight
             fail: event guard.caught
have_ledger  unlock: step.trade or step.steal

have_ledger has no objective of its own. It works like the back room at the Afterlife where both crews drop the goods, so everything after it can depend on a single ID instead of every later step having to repeat both routes. The choom is free to start both runs, and finishing one of them does not flatline the other. When the quest hits an ending, the runtime marks every step that is not done or failed as skipped, so the road you did not take shows up as crossed out in the journal, a gig left on the table.

The Harbormaster's Ledger as a step graph talk_ines unlocks an optional ask_gulls step and find_otter. find_otter unlocks trade and steal. Either one unlocks the join step have_ledger, which leads to two endings, return_ledger with outcome returned and sell_ledger with outcome sold. If steal fails on guard.caught, the failed ending caught is reached with outcome confiscated. The Harbormaster's Ledger talk_ines talk harbormaster ask_gulls optional hint find_otter reach otter_den trade collect fish.rare 5 steal interact ledger_chest have_ledger step.trade or step.steal caught outcome !confiscated return_ledger outcome returned sell_ledger outcome sold on guard.caught

Dashed: the optional step and the failure route. Outlined: endings. Filled: the join.

Nine steps, three endings. Trade and steal converge on one join step, and a failed steal opens its own ending.

Optional steps do not cost any extra eddies or any special handling. ask_gulls is the street tip that points you at the otter, but no other step depends on it, so it does not sit on any path to an ending, and the validator is able to work that out from the graph by itself without a netrunner holding its hand. The o flag you see in the file is only a hint for the tracker, so it can paint the step in different chrome.

Objectives count what comes down the wire

A step completes when the game pings it that something went down on the street, and the objective kinds are shorthand for exactly that. collect fish.rare 5 counts item.collected events where the item is fish.rare, and it completes when the count reaches five, like a fixer who only pays once the full haul is on the counter. kill counts actor.killed in the same way, like a merc tallying flatlined targets for the fixer, reach waits for the first location.entered, and talk waits for dialogue.ended.

For anything outside those kinds, a step can use event <name> [n], which means anything the engine can push onto the wire is able to drive a step. The engine-hooks template banishes a spirit with event spirit.banished 3. OQF has no clue what a spirit is, and it does not need one, because the runtime only counts three of those events, like a bouncer at the Afterlife clicking a tally at the door, and then completes the step while the engine keeps the ghost story to itself.

The detail I like most came out of an integration test, the kind of run where you catch the glitch before a player does. A talk objective originally matched the whole conversation, so if the choom backed out of the smuggler deal, the ledger was sold anyway, since the dialogue had still ended, which is about as wrong as a fixer paying out on a job you walked away from. The fix, logged as D20 in docs/00-decisions.md, lets a talk objective bind to one specific exit of the conversation: talk smuggler smuggler_deal#accept. Now, if you back out of the deal, nothing gets sold and no eddies change hands.

If your engine speaks its own street dialect for these events, you only have to translate once, like a fixer who keeps one phrasebook for every gang in the district. The events doc uses Cozy Coast’s fish.caught as the example, which is mapped to item.collected through the runtime’s eventMap.

Payouts and endings both hit the wire as events

Rewards are defined once, as a catalog on the quest that reads like a fixer’s price list, and steps list the reward IDs they collect when they reach done. A reward with an outcome set belongs to the quest as a whole, the big payday the fixer holds back, and pays out when that ending lands. The kinds are item, currency, xp, reputation, unlock, flag, event and custom. Every grant emits reward.granted, with the quest, step, reward, kind, ref and amount in the payload, so the engine can credit the choom from a single listener instead of wiring up a separate payout desk for each reward. Granted reward IDs are logged in state, which means re-entering a step does not pay the eddies out twice unless the step is marked repeat, so no merc gets to cash the same chit two times.

An ending is simply a step with an outcome. When the quest reaches it, the runtime marks that step done (or failed, for a failed outcome), marks the rest as skipped, sets the world flag outcome.<questId>.<name>, grants the quest-level rewards for that outcome and emits quest.<questId>.outcome. The reason the outcome becomes a flag is so that other quests can gate on it, the same way every fixer in Night City hears which gig you finished and how it went down, then prices your next job on that street cred. The chain template, for example, opens its second quest with the unlock outcome.repair_boat.repaired.

Failures are outcomes as well, and you write them with a leading !. In the Ledger, caught unlocks on step.steal == "failed" and carries !confiscated, which is the choom getting busted in the den and the harbor guard bagging the ledger like corpo security bagging evidence. The case that needed its own decision entry (D23) was what should happen when an ending step itself fails, and the answer is that it does not reach its outcome. A fail rule on an ending means that particular route is burned, while the choom can still reach the other endings through a different run. To see why this matters, imagine a “sell it” ending with a fail rule for the choom refusing the buyer. If failing that step closed the quest on sold, the quest would record a sale at the exact moment the choom walked away from the deal, which is a fixer booking eddies for a job that never went down.

Quest state is a small versioned data shard

Where a choom stands in a quest is stored as one versioned document per quest instance, a small data shard the runtime keeps on its deck. Every detail of the run, down to the last chit of eddies, lives in there. It holds questVersion, stateVersion (currently 1 in the runtime), a status, the state and counters of each step, quest-scoped flags, the outcome, the reward IDs already paid out, and an append-only log of transitions. Because nothing is ever wiped from the log, you can walk it backwards and replay the route the choom ran, like scrubbing a braindance of the whole gig frame by frame.

The runtime is the only netrunner that reads and writes this shard. runtime.save() hands you copies, and how the game stashes them is left entirely to the game’s own rig. Step and reward IDs are part of that save contract, so once a quest has shipped, the rule is that you never rename one, or every old save on the street goes gonk. The runtime does cope with steps being added or removed: a new step starts locked on the next load, and when you remove a step, its old state is parked under an x-oqf.orphaned key instead of getting flatlined, the way a ripperdoc keeps pulled chrome in a drawer in case the client comes back for it.

What OQF leaves on the engine’s side of the table

This is the half of the design I care about more, choom, because it decides how much work each engine has to put in, and how many eddies each crew burns, before it can load a quest.

OQF does not define actors, items or locations. They are references, meaning opaque engine IDs with an optional display name for tooling, so the engine stays the owner of what those things actually are, the same way a fixer knows a merc by handle and never asks what chrome sits under the jacket. There is also no scripting language, so no black-box daemons ride inside your content. Conditions are written in a subset of JSON Logic with eleven operators, and that limit is deliberate, because a validator can reason about a condition it is able to read, and it cannot crack a Lua snippet any more than a street kid can crack Arasaka-grade ICE. When a condition reads var.*, which are the game’s own variables, the validator treats the value as unknown and tells you that the completability guarantee just got weaker. The Ledger’s steal route reads var.time.isNight, and the validator can still prove that both clean endings are reachable through trade, the route with no night shift in it.

OQF also does not run dialogue, own the save file, or draw the tracker, the journal or the map markers, since those are the parts every game dresses in its own chrome. Timers are incoming in v2, and until they jack in, the failure template burns its sale on event world.nightfall, which leaves the call about when night falls over the city to the engine. A custom objective never completes on its own; the engine reports it by emitting quest.<questId>.step.<stepId>.custom with { done: true }, the way a merc calls the fixer to say the job is done. Finally, anything engine-specific goes into an extension bag under a namespaced key such as x-godot or x-cozycoast. Every tool carries those keys like sealed cargo on a smuggler run and no OQF feature ever cracks them open, so an engine can keep the data it needs in the quest file without the format having to understand it.

The reason I hold that line is that every feature bolted onto the model is chrome every engine loader has to install, and every install costs eddies and a chair at the ripperdoc. The README claims that a loader is a few hundred lines because the format is simple on purpose, and I would rather keep that claim true than win an argument about factions, which is one gang war I am happy to sit out for now. Factions, radiant quests and tabletop export sit on the v2 list, and they are designed to slot in without breaking v1 files or zeroing anyone’s existing content.

You can already see where the model needs more chrome in the Ledger’s trade step. What it really wants is two objectives: collecting five koi and having a sit-down with the otter, the way any fixer wants both the eddies and the handshake. In 0.1.0 a step carries only one objective, so the conversation half is handled through a dialogue binding and the flag flag.otter_agreed, which the step checks alongside the koi, a workaround that runs clean but still looks like tape holding a cyberdeck together.

Incoming, choom. A list of objectives per step is a candidate for v0.2, planned for once the runtime has shipped in Cozy Coast and the need is confirmed on the street, and it would let trade call both of its shots directly. Engine exporters for Godot, Unity and Unreal hit the street in v1.5. Timers, factions, radiant quests and tabletop export roll in with v2, and Cozy Coast is lined up as the first shipping game to run the format, the first rig to take this chrome out into the wild.

The spec, the templates and the runtime are all on GitHub under the Apache 2.0 license, open to any netrunner who wants to jack in and fork the deck.

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