How to debug a Questmark document
Questmark documents fail in a few characteristic ways. This guide walks through finding the problem quickly: compile first, then play one step at a time, then inspect the compiled bytecode.
1. Compile without running
Most authoring mistakes are compile errors. Check the document without starting a play session:
npx questmark --input path/to/document.md --no-runInside a checkout:
npm start -- --input path/to/document.md --no-runIf nothing is printed and the command exits with status 0, the document compiled cleanly. A thrown error names the offending construct.
You can also use the playground: press Run and read the error panel. It shows the same compile/runtime error without leaving the browser.
2. Read the error message
| Message | Cause | Fix |
|---|---|---|
getContext: null/undefined can not be pushed to the context! | A key was read before it existed. | Add it to initial-context, or guard with hasContext. |
Cannot find function in function table: foo | An opcode name is misspelled or not provided by the VM. | Fix the spelling, or prefix host hooks with _. |
gt: stack param 1 is not a number! | The operand on top of the stack is a string, or the stack order is wrong. | Check what is on top; see stack order. |
Unknown 'options.no_link_behaviour' | The header flag has an unsupported value. | Use "loopback_to_options" or "exit". |
Option text contains a backtick warning | Inline code was used in an option label. | Move the code after the label. |
| A state's text or options are missing | A ## heading split the state. | Use only # for states. |
Some errors only happen at runtime, not compile time: an unknown opcode is detected when the VM loads the compiled state, and getContext throws when the state containing it runs. --no-run cannot catch those; play the document or use the playground.
3. Play with the CLI and read the output
npx questmark --input path/to/document.mdThe CLI prints emitted text as it happens, then the menu. If the text appears without a menu, the state ended with exit or has no options. If the menu repeats identically forever, an option has no link and no effect and no_link_behaviour is looping (the default).
To bisect a long document, temporarily change initial-state to the state you suspect, then play just that state.
4. Inspect the compiled state
Compile to JSON and look at what the compiler produced:
npx questmark --input path/to/document.md --output /tmp/vmstate.json --no-runUseful checks:
labelMapshould contain one entry per state, plus<state>__optionsentries. A missing state means its heading was not top-level or its name collides with another.programListshould not be empty. An empty program usually means the document has no#headings.- The first instructions are the
setContextsequence forinitial-context, followed by agotoforinitial-state. If they are missing, the header JSON is malformed or uses the wrong field names.
A short Node script can load the JSON and run it without the CLI:
const fs = require("fs");
const { QuestVM } = require("questmark");
const state = JSON.parse(fs.readFileSync("/tmp/vmstate.json", "utf8"));
const vm = new QuestVM(
(text) => process.stdout.write(String(text)),
async (choices) => {
console.log(choices);
return choices[0].id;
}
);
vm.loadVMState(state);
vm.run();5. Check the three usual suspects
- Uninitialized context. Every key read with
getContextmust exist. UsehasContextwhen presence is genuinely optional. - Stack imbalance. Every snippet should leave the stack as it found it, except for the single value a precondition is supposed to leave. A stray value changes the next
jgz. - Markdown structure. Labels are plain text; options are one unordered list at the same level;
#is for states;##breaks states.
6. Use the playground for fast iteration
The playground runs the real compiler and VM in your browser. It is the quickest loop for authoring:
- Paste the document.
- Press Run.
- Change one thing.
- Press Run again.
The output panel shows the emitted text, the current choice menu, and the error message when something throws. Because the playground starts a fresh VM on each run, it is also the easiest way to test @once and context from a clean state.
7. Inspect the VM state
Both the inline Play tab and the playground have an Inspect state panel under the story. Open it while the document is waiting for a choice. It shows:
- Stack — the values currently on the interpreter stack. For a healthy document this is empty at every menu.
- Context — the context bag, sorted by key. Keys beginning with
__are runtime bookkeeping (@onceuses__once__N).
The snapshot refreshes whenever a menu is built, when the VM exits, and when an error occurs. Use it to:
- check what a precondition actually sees (
trustis 2, not 3); - confirm an effect ran (
coinschanged after the choice); - spot a leaked value before it changes a later gate. A non-empty stack at a menu means some snippet pushed without consuming; find it in the option effect or state text and balance it;
- confirm that
@onceconsumed a topic by looking for a__once__key.
The panel is read-only. It does not modify the VM, so you can leave it open while you play.
8. Add temporary instrumentation
Because emit prints any value, it is a reliable debugger:
DEBUG trust=`"trust" getContext emit` key=`"key" hasContext emit`Put a line like that in a state while you are working, then delete it. For a structured log, use a host hook:
* Choose this `_logTrust`and register _logTrust in additionalFunctions to print the context. The playground ignores unregistered _ opcodes, so the document remains playable without the host.
Checklist
- [ ]
--no-runexits cleanly. - [ ]
initial-statenames a real state. - [ ] Every
getContextkey is initialized or guarded. - [ ] Each precondition leaves exactly one number.
- [ ] Option labels are plain text.
- [ ] States use
#, not##. - [ ] Every non-terminal state has a way out.