12 min read

What Skyrim and Baldur's Gate 3 quests have in common

A walkthrough of the Open Quest Format model: how steps form a flat graph, how objectives count events, how rewards and endings are emitted as events and flags, and why so much is left to the engine on purpose.

If you open the quest journal in Skyrim and then the one in Baldur’s Gate 3, the shapes look familiar. In both games you usually have several objectives running at once, some of them are marked optional, there is more than one way through the middle of a quest, and the ending you choose is remembered by the rest of the world afterwards. The engines underneath have nothing in common, and yet the quests are clearly built from the same pieces.

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 engine. Today it exists as a published spec with a reference parser, validator and runtime in TypeScript, all at 0.1.0. Engine exporters are on the way, and Cozy Coast is lined up as the first shipping game to run it. Since no game has shipped on it yet, this post is a tour of 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 exercise every v1 feature once, so it is a good place to see how the pieces fit together in a single quest.

A quest is a flat list of steps

The first decision was to avoid nesting entirely. 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. That also means the order in which you author the steps has no meaning for the runtime.

Each step moves through a small set of states:

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

Three data-driven transitions move a step between those states. unlock takes it from locked to available, complete takes it from active to done, and fail moves it to failed. There is a fourth one, activate, which exists for the gap between “this is now possible” and “the player has actually taken it on”. Plenty of quests do not need that distinction, so when activate is absent the step goes live the moment it unlocks.

Because steps only point at each other, several of them can be active 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, three jobs open up together, and the last step waits until all of them are finished. 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 join the three branches back together, so there is no separate join construct to learn. The same template also has a step, lanterns_halfway, that unlocks on count.gather_lanterns >= 2, which means it can fire off another step’s counter before that step is finished. And there is bake_pies, a bonus step that nothing else waits on.

Two routes to the same step

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 has the harbor ledger, and you can either bring him five silver koi or sneak into his den at night and take it.

To give one step two routes, you write two sibling steps and then a third step that unlocks on either of them:

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 is there so that everything after it can depend on a single ID, instead of every later step having to repeat both routes. The player is free to start both routes, and finishing one of them does not fail the other. When the quest reaches an ending, the runtime marks every step that is not done or failed as skipped, so the route you did not take shows up as crossed out in the journal.

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 need any special handling. ask_gulls 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. The o flag you see in the file is only a hint for the tracker, so it can style the step differently.

Objectives count events

A step completes when the game tells it that something happened, 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. kill counts actor.killed in the same way, 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 emit is able to drive a step. The engine-hooks template banishes a spirit with event spirit.banished 3. OQF has no idea what a spirit is, and it does not need to know, because the runtime only counts three of those events and then completes the step.

The detail I like most came out of an integration test. A talk objective originally matched the whole conversation, so if the player backed out of the smuggler deal, the ledger was sold anyway, since the dialogue had still ended. The fix, recorded 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 is sold.

If your engine uses different names for these events, you only have to map them once. The events doc uses Cozy Coast’s fish.caught as the example, which is mapped to item.collected through the runtime’s eventMap.

Rewards and endings both go out as events

Rewards are defined once, as a catalog on the quest, and steps list the reward IDs they grant when they reach done. A reward with an outcome set belongs to the quest as a whole and is granted when that ending is reached. 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 player from a single listener instead of handling each reward separately. Granted reward IDs are recorded in state, which means re-entering a step does not pay out twice unless the step is marked repeat.

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 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. 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 no longer available, while the player can still reach the other endings. To see why this matters, imagine a “sell it” ending with a fail rule for the player refusing the buyer. If failing that step closed the quest on sold, the quest would record a sale at the exact moment the player turned it down.

Quest state is a small versioned document

Where a player stands in a quest is stored as one versioned document per quest instance. 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 granted, and an append-only log of transitions. Because nothing is ever removed from the log, you can walk it backwards and reconstruct the route the player took.

The runtime is what reads and writes this document. runtime.save() hands you copies, and how the game persists them is left entirely to the game. Step and reward IDs are part of that save contract, so once a quest has shipped, the rule is that you never rename one. 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 being thrown away.

What OQF leaves to the engine

This is the half of the design I care about more, because it decides how much work each engine has to do 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 remains the owner of what those things actually are. There is also no scripting language. 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 reason about a Lua snippet. 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 good endings are reachable through trade.

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 presents in its own way. Timers are incoming in v2, and until they land, the failure template fails its sale on event world.nightfall, which leaves the decision about when night falls 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 }. Finally, anything engine-specific goes into an extension bag under a namespaced key such as x-godot or x-cozycoast. Every tool preserves those keys and no OQF feature ever reads them, 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 added to the model is a feature every engine loader has to implement. 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. Factions, radiant quests and tabletop export are on the v2 list, and they are designed to slot in without breaking v1 files.

You can already see where the model needs to grow in the Ledger’s trade step. What it really wants is two objectives: collecting five koi and having a conversation with the otter. 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.

Incoming. 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 there, and it would let trade state both of its requirements directly. Engine exporters for Godot, Unity and Unreal follow in v1.5. Timers, factions, radiant quests and tabletop export arrive with v2, and Cozy Coast is lined up as the first shipping game to run the format.

The spec, the templates and the runtime are all on GitHub under the Apache 2.0 license.

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