Code Editor
The code editor allows more code-savy writers to write sections, or their entire story, in code. Anywhere you see an “Edit as code” button - on a scene, on an effects panel, or inside a choice’s effects popover - clicking it opens a full-screen modal with an IDE and a live preview of how many effects you’ve written and any errors.
It’s optional. Every feature is also available in the GUI. The code editor exists for moments when clicking would be slow: for instance ten weighted or random damage rolls for a combat round, a block of scene text with inline conditions and variable references, or reorganizing a scene’s choices in one pass. You could also easily copy and paste choices with all their conditions and effects from one scene to another.
Two Scopes
The editor runs in two scopes depending on where you open it from:
- Scene scope: opened from the scene editor’s toolbar. The editor contains the whole scene: body text, on-enter effects, and every choice with its conditions and effects.
- Effects scope: opened from an effects panel (on a choice, or a scene’s on-enter block). Each line is one effect.
The grammar is the same in both scopes. Scene scope just wraps scene and choice effects lines in text:, on enter:, and continue choice "…": blocks.
Effects Grammar
One effect per line. Lines starting with // are comments. Blank lines are ignored.
Heads-up on where comments persist.
//comments only survive a save when they’re inside atext:block — there they become Writer Comment blocks in the visual editor (visible to writers, hidden from readers). Comments written between top-level blocks, insideon enter:, or inside a choice body have nowhere to live in the structured save, so the editor shows a lint warning and drops them on save. Move the note into atext:block if you want it to stick.
Highlight-and-comment annotations don’t appear in code. The visual editor’s selection bubble menu has a Comment button that drops a threaded annotation on a piece of selected text — the kind you’d use for review feedback (“rephrase this”, “is this branch finished?”). Those threads carry off-document state (author, replies, resolved status) and live in a separate annotations table, not in the scene’s body. The code editor doesn’t render them, and writing
//in code won’t create one. To leave or read review threads, switch to the visual editor.
Variable Effects
PlayerDamage = 5 // set static
PlayerDamage += 5 // add (also -=, *=, /=)
EnemyHealth *= 2 // multiply
EnemyHealth /= 2 // divide
EnemyHealth -= weapon_damage // subtract a variable
PlayerDamage += weapon_damage + 5 // compound formula
reputation += npc:Mara // reference NPC / faction sentiment
PlayerDamage = rand(+1 to +6) // random range
PlayerDamage += roll(2d6+3) // dice roll
loot = oneOf(5, 10, 15) // one numeric value
Rarity = oneOf("common", "rare", "legendary") // one string value
HasKey = true // boolean
PlayerName = "Alice" // string
Greeting = "Hello, world" // literal comma string For oneOf(...), numeric options are unquoted and string options are quoted. oneOf(common, rare) is invalid because unquoted string options are ambiguous.
Use normal quoted strings for literal text that contains commas, like "Hello, world".
Formulas on the right-hand side can reference other number variables, NPC
sentiment (npc:Name), and faction sentiment (faction:Name), and combine
them with +, -, *, /. Multi-word variable names use underscores in
formulas (weapon_damage matches a variable named weapon damage). Formulas
only apply to number-typed targets — string and boolean variables still need a
quoted literal or true/false.
Probability Prefix
Wrap an effect in N%: to make it fire with a given chance.
15%: PlayerDamage += 6
50%: PlayerDamage = 0
(50 + speech * 2)%: gold += 100 // formula (base + variable * multiplier) Conditional Effects
if EXPR: gates an effect on a condition. You can combine clauses with and, or, and parentheses.
if gold > 100: Health = 50
if gold > 100 and speech > 5: reputation += 1
if (gold > 100 and speech > 5) or charisma > 10: gold -= 50
25% if gold > 100: Health += 10 // probability + condition stacked Supported operators: =, !=, >, >=, <, <=, discovered, !discovered, at_scene, !at_scene.
Engine-tracking conditions
The engine tracks how many times each scene has been visited and each choice clicked. Reference those counts inside a condition with scene:"Name" or choice:"Label":
if scene:"Bank Lobby" >= 2: reputation += 1 // after two visits
if choice:"Attempt bribery" > 0: gold -= 50 // the player has bribed before
when scene:"Tavern" >= 1 and speech > 5 // choice visibility gate The label is always quoted because scene names and choice labels usually contain spaces or punctuation. See Variables & Effects for background on scene-visit and choice-click tracking.
Entity Effects
npc:Mara += 5 // add sentiment
npc:Mara discover // mark discovered
faction:Guild = -10 // set sentiment
persona:Hero // set the player's persona Use the npc: / faction: / persona: prefix when an entity’s name would collide with a variable name. Otherwise the bare name works when it is unambiguous.
Audio
play "TavernMusic" // default volume 100
play "TavernMusic" at 80% Dynamic Choices
Inside a dynamic choice’s effects, the right-hand side can reference the iterated entity’s tag properties via {propertyName}. At runtime the engine resolves the property for whichever entity produced this choice (e.g. when “Eat Canned Chicken” is picked, {satiety} resolves to Canned Chicken’s satiety).
Health += {satiety}
Health -= {satiety}
Health = {satiety} See Tags & Dynamic Choices for background.
Dropdown Choices
Inside a dropdown choice, every line is one option and must end with as "Label". The left-hand side is the variable assigned when the option is picked; the label is what the player sees.
Weapon = "Sword" as "Sharp sword"
Weapon = "Bow" as "Reliable bow"
Weapon = "Staff" as "Magic staff" Probability prefixes and if are not allowed on dropdown options — they set one value per pick, nothing more.
Scene Grammar
Scene scope wraps effects grammar in three block types:
text:
The guard blocks the doorway, one hand resting on his sword.
"State your business," he barks.
on enter:
reputation += 1
if gold > 100: HasReputation = true
timer 30 default "Leave quietly"
interact choice "Attempt bribery":
when gold >= 50
goes to "Bribed Outcome"
gold -= 50
50%: reputation -= 1
continue choice "Leave quietly":
goes to "Town Square"
reputation += 1 text: Block
The body of the scene, as a Markdown dialect. Headings, lists, bold/italic/strikethrough/underline/subscript/superscript, images, links, inline code and code blocks, blockquotes, horizontal rules, message blocks, writer comments, conditional blocks ({if gold > 20: you are rich}), polls, inline sound effects, text variations, and inline variable references ({gold}, {npc:Mara}) all round-trip — what you see in the rich editor appears in the code, and what you type in the code becomes rich formatting on Save.
Inline formatting uses **bold**, _italic_, ~~strike~~, and `inline code`. Italic uses underscores (not single asterisks) so * is unambiguously bold.
Paragraphs and headings that are centered or right-aligned use raw HTML tags in the code editor:
<p align="center">Centered paragraph</p>
<p align="right">Right-aligned paragraph</p>
<h2 align="center">Centered heading</h2> Left/default alignment uses normal Markdown with no extra wrapper.
Images use a single tag in the code editor, even though the rich editor stores them as a figure node with nested image/caption children:
<image alt="Mara portrait" caption="Portrait of Mara" align="center" /> If a caption contains line breaks, the code editor collapses them to spaces because the visual editor does not expose multiline captions. The code editor intentionally hides the underlying image URL and file-title metadata; those are preserved automatically when you round-trip through code.
Inline sound effects use the same idea: only the human-meaningful name is shown in code.
<sfx name="door creak" /> The name must match one of the uploaded sound effects in the story. The editor preserves the internal audio id, URL, and other hidden metadata when you round-trip through code.
Text variations pick between alternative phrasings on each render. They match the visual editor’s “Add text variation…” menu:
| Form | Behavior |
|---|---|
{random: Hello \| Hi \| Greetings} | Pick one variant uniformly at random every time the scene renders. |
{randomOnce: Red \| Blue \| Green} | Pick once on first render; keep the same choice for the rest of the playthrough. |
{cycle: First \| Second \| Third} | Advance through the variants one per visit, wrapping back to the start. |
{cycle:greeting: Hello \| Hi} | Named cycle — multiple blocks sharing the same name advance in lockstep. |
on enter: Block
Effects that fire when the reader enters the scene. Uses the full effects grammar above.
timer Line
A single line at the scene’s top level that shows a countdown when the reader enters the scene.
timer 30 // 30-second timer, no fallback
timer 30 default "Run away" // auto-fires the matching choice when it expires The number is in seconds and must be a positive integer. The optional default "Choice label" clause names the choice to auto-fire when the timer expires — the label resolves against the choices in the same source, so renaming a choice in the same edit still binds correctly. Omit the line entirely to clear any existing timer (absence is “no timer”, same convention as goes to).
<type> choice "…": Block
One per choice. The header always begins with the choice’s type so the source is explicit and round-trips losslessly.
| Header form | Choice type |
|---|---|
continue choice "Label": | Continue (default — also moves to goes to target on click) |
interact choice "Label": | Interact (info button — does not advance the scene) |
reusable interact choice "Label": | Interact, reusable across visits |
back choice "Label": | Back (returns to the previous scene) |
input choice "Label" into VarName: | Input (player types a value into VarName, which must be a string variable) |
dropdown choice "Label": | Dropdown (one option per body line, each Var = Value as "Option label") |
reusable dropdown choice "Label": | Dropdown, reusable across visits |
dynamic choice "Eat {item}": | Dynamic (label and effects iterate over a tag; effects can use {propertyName}) |
Inside the body:
when EXPR(optional) — the choice’s visibility condition. Same operators asif, withand/or/parentheses.goes to "Scene Name"(optional, continue + input choices only) — the scene this choice navigates to. Click the scene name to jump to it inside the modal. Other choice types (interact, dropdown, back, dynamic) don’t expose a writer-set scene link.- Any number of effect lines, using the full effects grammar. Dropdown bodies must use
as "Label"on every line; dynamic bodies may use{propertyName}on the right-hand side.
Choices reorder, add, and delete by editing the source — the order of blocks becomes the order of the choice list, removing a block deletes the choice, and writing a new <type> choice "…": block adds one. Existing choices reconcile by label, then position, so renaming one and adding a new one in the same edit still works correctly.
A bare choice "Label": (no type) parses as a continue choice and the editor flags it with a warning prompting you to add an explicit type.
Saving
The editor saves automatically as you type, on a half-second debounce — there’s no Save button. The header shows a familiar Saving… → Saved indicator (the same one the visual editor uses), so you can see at a glance whether your last edit has been committed. Click Back when you’re done; any pending change is committed before the modal closes.
Auto-save pauses in two cases, both shown as “Unsaved changes” in the header (hover the indicator for a tooltip explaining why):
- The source has errors — the editor won’t commit a half-broken effects list or scene body. Fix the error and the save fires automatically.
- The buffer is empty — clearing every effect (or every scene-body line and on-enter effect) is treated as a likely-accidental wipe and blocked. Restore something or close the modal to keep what was there before.
Error Handling
As you type, the editor lints the source and draws red squigglies under errors. Hover the squiggly for a tooltip. The status bar at the bottom shows the total effect count and error count; the error list below it jumps you to each one.
Common Errors
| Error | Meaning |
|---|---|
Unknown target 'xyz' — not a variable, NPC, or faction | No entity named xyz exists. Create it first, or check the spelling. |
Unknown persona 'xyz' | No persona with that name is defined on the story. |
Unknown audio 'xyz' — no track with that title in this story | The play "…" title doesn’t match any audio track. |
Unknown tag property 'xyz' | A {propertyName} reference names a property not defined on the choice’s tag. |
as "Label" suffix is only allowed on dropdown choices | You used the dropdown-option suffix in a non-dropdown choice. |
Dropdown options must use '=' (not '+=', '-=', '*=', or '/=') | Dropdown options only support = — no arithmetic operators. |
Dropdown options need a label, e.g. 'Weapon = "Sword" as "Sharp sword"' | A dropdown option is missing its as "Label" suffix. |
{property} references are only allowed on dynamic choices | Tag-property references work only inside a dynamic choice’s effects. |
Choice header is missing a type — write 'continue choice "…"', 'interact choice "…"', etc. | A bare choice "…": was used. Add an explicit type prefix (continue, interact, back, …) so the source is unambiguous. |
'reusable' is only valid before 'interact' or 'dropdown' | The reusable modifier was applied to a choice type that doesn’t support it. |
'into VarName' is only valid on input choices | An into clause was used on a non-input choice header. |
Input choice needs 'into <VariableName>' | An input choice header is missing the variable to write into. |
Unknown variable '<name>' for input choice | The into <name> clause names a variable that isn’t a string variable on this story. |
'goes to' is only allowed on 'continue' and 'input' choices | A goes to line appeared inside an interact / dropdown / back / dynamic block. Those types don’t expose a writer-set scene link. |
Unknown scene '<name>' | A goes to "<name>" references a scene whose name doesn’t match any scene in this story. |
Invalid condition: 'xyz' | A condition inside if or when couldn’t be parsed. Hint: use <name> <op> <value>, e.g. gold > 100. |
Could not parse effect line | The line doesn’t match any known grammar production. Hint: use <name> = <value>, <name> += <value>, npc:<Name> discover, play "<title>", or persona:<Name>. |
Probability 150% is out of range (must be 0–100) | A probability prefix is outside 0–100. |
Audio volume 250% is out of range (0–200) | An at NN% volume is outside 0–200. |
String oneOf options must be quoted | String random options need quotes: oneOf("common", "rare"), not oneOf(common, rare). |
oneOf(...) options must all be numbers or all quoted strings | A oneOf(...) list mixed numeric and string options. Use all numbers or all quoted strings. |
Number variables can only use numeric oneOf options | A number variable received quoted string options. Use numeric options such as oneOf(5, 10, 15). |
'discover' is only valid on npc or faction targets | Only NPCs and factions can be marked discovered. |
Boolean variables only support '=' (not '+=', '-=', '*=', or '/=') | Boolean variables can only be set, not altered arithmetically. |
Number variables can't be assigned 'xyz' | The right-hand side isn’t a recognized number literal, random, dice roll, or formula. |
Unexpected indentation | A top-level line is indented — move it to column 0 (scene scope only). |
Unknown block header | A scene-scope header isn’t text:, on enter:, timer …, or choice "…":. |
Timer duration must be a positive integer (got '<value>') | A timer N line had 0, a negative number, or a non-numeric value. Use timer 30 or remove the line to clear the timer. |
Duplicate 'timer' — a scene can only have one timer | Two timer lines in the same scene. Keep one and delete the other. |
No choice named '<label>' in this scene | A timer N default "<label>" references a choice that doesn’t exist in the source. Add the choice or fix the label. |
Comments outside a text: block are not saved (warning) | A // comment appears between blocks, inside on enter:, or inside a choice body — none of those places have a structured home for comments. Move the note into a text: block if you want it to persist (it becomes a Writer Comment), or delete it. |
ID Preservation
When the editor opens, existing effects are serialized with a faint // id:<uuid> comment above each line so that Save preserves their database IDs (and anything that points at them, like effect conditions). Lines you add without an ID get a fresh one on Save. The IDs are visible but dimmed so they don’t clutter the grammar.
Choices don’t carry visible IDs in the source — they reconcile by label and position instead. This means renaming a choice or reordering them in the source round-trips cleanly without UUID clutter, and the goes to "Scene Name" line on each choice keeps its scene link bound by name across edits.
Examples Sidebar
The right-hand sidebar has snippet examples grouped by category. Clicking one inserts it at the cursor. The search box filters snippets by title, description, or content. Examples adapt to the current mode — in a dropdown-choice context you’ll see as "Label" snippets; in a dynamic-choice context you’ll see {propertyName} snippets. Collapse it with the chevron in its header when you want the editor to take the full width.
Scenes Sidebar (Scene Scope)
When you open the editor in scene scope, a left-hand panel lists every scene in the story. The currently-edited scene is highlighted, and a search box at the top filters by name. Click any row to switch the editor to that scene without closing the modal — useful for hopping between related scenes while reorganizing a chapter. Like the examples panel, it collapses to a thin gutter when you don’t need it.
You can also rename scenes from this panel, just like in a file system: double-click any row, or click the already-selected scene a second time after a short pause (the macOS Finder gesture). An inline text field appears with the scene’s current explicit name (the field stays empty if you’ve never set one). Press Enter or click away to save, or Escape to cancel. Clearing the name and pressing Enter removes the explicit name, so the scene falls back to its first-line label.
If your current source has unsaved edits, the editor auto-commits them before switching scenes — except when there are syntax errors, or when the auto-save would wipe the scene body or on-enter effects (e.g. you’ve cleared the editor). In those cases the hop is blocked and you’ll see a toast explaining why.
Clickable Scene References
Anywhere a scene is referenced by name — goes to "Scene Name" on a choice, or scene:"Scene Name" inside a condition — the label gets a dotted underline. Click it to jump to that scene. Cmd/Ctrl/Alt-click and right/middle clicks fall through to Monaco so multi-cursor selection still works as expected.
Tips
- The editor is heavy (Monaco ships about a megabyte of code) so it’s loaded lazily the first time you open it. Subsequent opens are instant.
- Standard IDE conventions apply:
Ctrl/Cmd+Fto search,Ctrl/Cmd+Zto undo, multi-cursor withAlt+Click, etc. - Closing via Back flushes any pending auto-save first, so a quick edit-and-close still persists. Just remember that the wipe and error guards still apply — if the indicator says “Unsaved changes” when you click Back, those edits won’t be committed.
Related
- Variables & Effects — the underlying data model that effects edit.
- Scene Editor — the rich click-driven view that the code editor mirrors.
- Tags & Dynamic Choices — context for the
{propertyName}syntax.