Why I wrote an open quest spec instead of a plugin
Open Quest Format 0.1.0 is an Apache-licensed, versioned quest spec with a reference parser, validator and runtime, so quest content can outlive the engine or plugin you picked first.
When you pick a quest plugin, you are also picking the only place your quest content will ever be able to live. A Unity asset works in Unity and an Unreal data table works in Unreal, and a paid plugin keeps working for as long as its author keeps shipping updates, but no longer than that. The problem is that everything you wrote inside it, every step, branch and reward, is stored in a shape that only that one product knows how to read.
I started Open Quest Format because I wanted a way out of that situation. Version 0.1.0 went out on 16 September, and what I am aiming for is a quest format that nobody owns, including me, so that nobody’s quests depend on a single vendor staying around.
How quest data gets locked to one tool
On paper, quest data should be easy to move around. If you strip away the UI, every quest system from Skyrim to Baldur’s Gate 3 is built from the same parts: steps, the conditions that unlock them, the objectives that complete them, rewards and endings. None of those ideas depend on a particular engine.
What makes them engine-specific is the way they get saved. The plugin stores your quests as its own asset type, its own table schema or its own serialized blob, and nobody documents that shape for you, because it was never meant to be read by anything else. In practice that means switching engines turns into rewriting all of the content by hand, and moving to another plugin inside the same engine means exactly the same work. And if the asset you bought stops getting updates, that is usually the moment you realise your quests were only ever as alive as the one person maintaining it.
Why small teams are hit hardest
A studio with a tools team can write its own quest system and own the format outright, so this is mostly not their problem. The people who buy quest plugins are solo developers and small teams, because building a quest editor is about a month of work, and nobody on a two-person project has that month to spare. They are also the people most likely to prototype in one engine and ship in another, or to put a project aside for half a year and come back to find the asset has been removed from the store.
I am in that group myself, since I build Cozy Coast alone, on Electron and Phaser. If I had written a quest system directly inside the game, it would have been one more private format that only one game could ever read. So I decided to write the format first, and the reference quest in the repo, The Harbormaster’s Ledger, was written for Cozy Coast.
Choosing an open spec over a plugin
Plugins usually only work within one engine, and they have been tailored to enhance how that engine works. This is where the difference is: an open spec is independent of any engine, and it focuses on being translatable by any system. What I wanted to protect was the content itself, and content is only safe if its format is written down somewhere that anyone can implement it without asking for permission. That is why OQF is a published spec before it is anything else, and why it is released under the Apache 2.0 license.
We want to make sure that, as the format evolves, we can always tell which version a file was written for and spot any differences in the future. So every compact file carries its version in its first line, OQF1. That number only changes if a file written today would stop parsing, which is exactly what the project exists to prevent. For the same reason, columns in the compact format are append only: new columns are added at the end of a record, and existing ones are never reordered or removed. When a parser reads a record with more cells than it knows about, it keeps the extras under x-oqf.extra, which means an older tool can pass newer data through without losing any of it.
I also did not want the spec to exist only as a document, because the easiest way to find the gaps in a spec is to implement it and run real quests through the code. So the spec ships with a reference implementation in TypeScript: @oqf/core for the model, the parsers and the validator (no I/O, no dependencies), @oqf/runtime for the state machine, @oqf/dialogue for conversations with Ink and Yarn Spinner import and export, @oqf/i18n for translation tables, a CLI and a web editor. The rule across all of these is that everything converts to or from the model in @oqf/core and never around it, so there is only one definition of what a quest is.
The README says an engine loader should take a few hundred lines, because the format is deliberately kept simple. I wanted to check that claim against the one loader I actually have, and packages/core/src/compact/parse.ts comes to 426 lines by wc -l, comments included.
Conditions are usually where quest systems drift into becoming a scripting language, and I wanted to avoid that, so OQF does not embed one. A condition is written as a line of infix text such as step.find_net, and it is stored as a JSON Logic tree that can only use eleven whitelisted operators. I picked JSON Logic because it already has implementations in JavaScript, C#, Python, Go, Rust, Lua and GDScript, which means a Godot loader can take its evaluator from a library that exists today instead of writing a new one. And since conditions are plain data and nothing in a quest can run arbitrary code, the validator is able to walk the whole step graph and tell you that a step is unreachable before a player runs into it.
For the native file I went with a line oriented, interned layout. Actors, locations and items are declared once and then referenced by index, and the first character of each line tells the parser what kind of line it is. That is the same approach I used for NWF, and it keeps the files small. On the reference quest in @oqf/examples (nine steps, three endings, one of which is a failure), the .oqf file is 2,017 bytes, while the JSON form of the same quest is 8,551 bytes as it ships, pretty-printed, and 4,189 bytes after JSON.stringify with no indentation. JSON is still a first-class output with a published schema, so if you would rather not work with the compact form, you never have to touch it.
A minimal quest in the compact format
Here is the smallest quest that does something useful, with two steps and one ending. The cells on each line are separated by TABs.
OQF1
L dock|North dock
I net|Fishing net
Q lost_net 1 The Lost Net Maren lost her net on the reef.
R xp_done xp xp 25 returned
S find_net s collect net Find the net
S return_net step.find_net reach dock returned|Net returned 0 Bring it back to Maren
L and I are the location and item dictionaries, and Q opens the quest. The R line defines a reward of 25 xp that is tied to the returned outcome. The first S line is a step flagged s for start, which gets completed by collect net. The second step unlocks on step.find_net, completes on reach dock, and ends the quest with the outcome returned. Any empty cell is a field that this particular quest does not need.
You can run the validator straight from npm without installing anything first:
$ npx @oqf/cli validate lost-net.oqf
0 errors, 0 warnings
After that comes the runtime. The idea is that world events go in and quest state and quest events come out, and the runtime does not need to know anything about your engine to do that.
import { readFileSync } from 'node:fs'
import { parseCompact, validateDocument } from '@oqf/core'
import { QuestRuntime } from '@oqf/runtime'
const doc = parseCompact(readFileSync('lost-net.oqf', 'utf8'))
if (!validateDocument(doc).ok) throw new Error('fix the quest first')
const runtime = new QuestRuntime({ documents: [doc] })
runtime.on((event) => console.log(event.name, event.payload))
runtime.offer('lost_net')
runtime.accept('lost_net')
runtime.emit('item.collected', { item: 'net', amount: 1 })
runtime.emit('location.entered', { location: 'dock' })
saveGame.quests = runtime.save()
I ran that code against the 0.1.0 source before writing this post. Every step transition is emitted as its own event, so you can follow each step as it goes from locked to available to active to done:
quest.lost_net.offered {}
quest.lost_net.step.find_net.available { from: 'locked' }
quest.lost_net.step.find_net.active { from: 'available' }
quest.lost_net.step.find_net.done { from: 'active' }
quest.lost_net.step.return_net.available { from: 'locked' }
quest.lost_net.step.return_net.active { from: 'available' }
quest.lost_net.step.return_net.done { from: 'active' }
reward.granted {
quest: 'lost_net',
step: 'return_net',
reward: 'xp_done',
kind: 'xp',
ref: 'xp',
amount: 25
}
quest.lost_net.outcome { name: 'returned', failed: false }
On the engine side, crediting the xp only takes one listener on reward.granted. If your game already uses a name other than item.collected for pickups, you can give the runtime an event map, so you keep your own vocabulary instead of renaming your events to match OQF.
runtime.save() returns a small versioned state document containing the step states, the counters, the outcome, the rewards that were already granted, and a log of every transition with its order. The granted rewards are recorded so that re-entering a step never pays out twice, unless the step is marked to repeat. Where you store that document is up to your game, because OQF is not meant to own your save file, now or later.
What is coming next
Incoming. Version 0.1.0 ships the spec, the TypeScript reference runtime, the validator, the CLI and the editor, and the next pieces are already planned. Engine exporters for Godot, Unity and Unreal are coming soon in v1.5, Cozy Coast is set to be the first shipping game, and timers, factions, radiant quests and tabletop export are planned for v2.
The exporters will arrive in that order, starting with Godot, then Unity, then Unreal, and each one comes with a loader on the engine side and one sample project. Until they are available, the TypeScript runtime is the one to use.
Cozy Coast is set to be the first shipping game on OQF, with @oqf/runtime as the loader from day one, and that integration is what I am working on next. The roadmap is explicit about what counts as done: a player has to finish the reference quest in a Cozy Coast build through every route and every outcome, and the state has to survive a save and a load. That is the bar I set before I say “it runs in a real game”, and it is what I am building toward now.
Today a step can only have one objective. The reference quest actually needs two on its trade step (collect five silver koi and talk to the otter), so for now the talking part is handled through a dialogue binding and a flag. Supporting a list of objectives is a candidate for v0.2, but I would rather wait until the runtime has lived inside Cozy Coast long enough to show that it is really needed.
The packages are still 0.x and share a single version number, and the TypeScript API may still change between minor versions while the rest of the features land, so I would recommend pinning a minor range. The file format is the part that stays frozen, because the v2 features are designed so that they can land without breaking a single OQF1 file.
How to try it
Like the validator, the editor runs without an install. Running npx @oqf/editor serves it locally on port 4600, and everything then happens in your browser: you draw steps on a graph, edit every field with live validation, and play the quest through @oqf/runtime in the Play tab. You will need Node 24 or newer for it. The easiest way to start is from one of the nine templates, and linear, for example, is a longer version of the lost net quest, with Maren pacing the north dock.
The spec, the reference quest and a folder of deliberately broken fixtures all live in the repo on GitHub. If you write a loader for your engine, or you find a quest that the validator should have caught and didn’t, please open an issue, because at this stage that kind of report is the most useful thing anyone can give the project.