Block Controls
Note
Status: implemented. A block if/elseif/else —
grouped, mutually exclusive branches with an optional fallback — compiles to a
branch node whose arms carry conditions. The runner tries the arms in order and
takes the first whose condition holds (see
Asking the World).
Table of contents
- Goal and scope
- Ubiquitous language
- Prior art
- Chosen shape
- Writer-facing behavior
- Grammar
- Architecture
- Interfaces and responsibilities
- Key design decisions
- D1 — Recognize in the transpiler, from the blockquote structure
- D2 — The marker is the two-span form
- D3 — One connected blockquote, enforced; a severed chain is an error
- D4 —
ControlBlock/BranchmirrorChoices/Choice - D5 — Editor support offsets the
>authoring tax - D6 — A scene is a top-level unit; no heading inside a branch
- D7 — Separate every marker and utterance with a quoted blank line
- Markdown interaction
- Diagnostics
- Error and boundary cases
- Testability
- Alternatives not chosen
Goal and scope
A writer often wants a group of dialogue — several lines, a choice, a jump — to
play only under some game-state condition, and to fall back otherwise. The inline
condition (`key?`) guards a single jump, line, or
choice option, each independently and with no else. This construct is the complementary one: a block if/elseif/else —
grouped, mutually-exclusive branches with an optional fallback.
In scope: the surface shape (marker spelling and grouping), the grammar, the AST and how it is recognized while transpiling, diagnostics, and source spans.
Out of scope: negation and in-script expressions, as for every condition.
This note assumes the Conditions, Unquoted Keys, and Control Line notes and does not repeat them: a branch marker takes the condition primitive, which is unquoted by default, and the marker is a control line (an effect-only, speaker-less block). Read those, plus Progression Order, first.
Ubiquitous language
| Term | Meaning |
|---|---|
| Condition | The game-state boolean, `key?` (see Conditions). |
| Marker | The token that opens a branch: `if` / `elseif` (each with a condition) or `else`. A control line (see Control Line). |
| Branch | One arm — an if, an elseif, or the else — a guarding condition (none for else) plus its body. |
| Branch body | The blocks a branch plays when taken; may hold a nested control block. |
| Control block | The whole construct: an ordered list of branches, held in one connected blockquote. |
| Connected blockquote | A single > container holding every branch, continued across arm boundaries by a bare > line; a nested branch is a > > container. |
Prior art
Surveyed from primary sources, block conditionals divide into three terminator families.
| Language | Open / separator / else | Terminator | Family |
|---|---|---|---|
| Yarn Spinner | <<if>> / <<elseif>> / <<else>> |
<<endif>> |
explicit terminator |
| SugarCube (Twine) | <<if>> / <<elseif>> / <<else>> |
<</if>> |
explicit terminator |
| Ink | { cond: / - cond: / - else: |
} |
bracket |
| Harlowe (Twine) | (if:)[ / (else-if:)[ / (else:)[ |
] per hook |
bracket / hook |
| Ren'Py | if: / elif: / else: |
dedent | indentation |
Markdown ::: directives |
:::name (no separator) |
::: |
container fence |
Two lessons shaped the choice: a block conditional needs a defined boundary
(every mature language marks the end explicitly or by dedent), and Markdown
containers do not separate branches — Markdig's CustomContainerParser has no
branch-separator concept, so a flat if/elseif/else chain in one ::: block
would need a custom parser (confirmed against the Markdig source). A blockquote,
by contrast, is a first-class Markdig container that already nests, so the structure
comes for free.
Chosen shape
The evaluation behind each choice is condensed here; the alternatives weighed and rejected are listed under Alternatives not chosen.
Marker spelling — two spans
A marker is written as two code spans — a keyword span and the verbatim condition
span — `if` `Rich?` / `elseif` `Poor?` / `else`.
Unquoted keys make this decisive. An unquoted key is everything before the ?
sigil, spaces included (see Unquoted Keys), so a one-span
marker `if Rich?` would parse as a single condition on the key if Rich, not an
if guarding Rich. The two-span form sidesteps that: `if` is a bare,
sigil-less, quote-less span — a closed marker vocabulary that collides with nothing (a
condition needs a sigil, a value read needs quotes, a command needs parens) — and
`Rich?` is the verbatim condition, reusing its reader, highlighting,
and completion. It composes even with spaces: `if` `Is Alice rich?`.
Grouping — one connected blockquote
Every branch lives in one connected blockquote: `if`, each `elseif`,
and the `else` share a single > container, continued across arm boundaries by a
bare > line; a nested conditional is a nested > > blockquote.
Chosen for readability and visuals. A rendered comparison was decisive — three
separate blockquotes read as independent asides, while one connected blockquote
reads as a single mutually-exclusive construct: one left bar spans all arms, a nested
if is visibly nested, and a bare jump returning to the outer arm sits at the outer
bar, so a reader sees which branch owns it. The container also supplies the boundary
for free, so no terminator keyword (and no #END clash) is needed.
The authoring cost is real but offset by editor support. Connectedness couples the
arms through unbroken >, so each utterance's blank-line separator must be a bare >
line, and reflowing means maintaining > (and > > when nested). DialogueDown already
projects editor semantics from the compiler (see
Compiler-Projected Editor Semantics
and Source Editor Autocompletion); the same
seam maintains the > prefixes as the writer types, completes the markers, and
highlights them — so the friction is an editor affordance, not a manual chore.
Rendering in a stock preview
The marker spans render as clean monospace chips in any previewer, and the
connected blockquote renders as one indented, visibly grouped block. A stock preview
has no directive/admonition plugin and sanitizes HTML, which rules out :::
directives (shown literally), <details> (collapsed, hidden), and <aside>
(sanitizer-stripped); the blockquote needs none of them. The construct's richer view —
an indented branch tree — is also supplied by DialogueDown's
visualization report.
Writer-facing behavior
A gate guard reacts to the visitor's standing, and — only for the wealthy — to whether they are armed:
> `if` `Rich?`
>
> Guard: Ah — my lord. The gate is yours.
>
> > `if` `Armed?`
> >
> > Guard: But leave your blade at the post.
> >
> > `("disarm the visitor")`
>
> => [The courtyard](#the-courtyard)
>
> `elseif` `Poor?`
>
> Guard: Back to the gutter with you.
>
> `else`
>
> Guard: I don't know your face. What business have you?
The `if` opens the block; each `elseif` and the final `else` continue
the same blockquote (note the bare > separators). A branch body may hold several
utterances, a bare jump, a silent command, or a nested conditional (the > >
block above). Zero or one branch is taken at play time: the first true condition, the
optional else, or none when no condition holds.
A blockquote that is not led by a marker is a transparent wrapper: its inner blocks read as ordinary content, in place.
Grammar
The construct is expressed over Markdown block structure: a blockquote whose blocks are marker paragraphs and branch bodies.
(* One connected blockquote: an if-arm, then elseif-arms, then an optional else-arm. *)
ControlBlock = BlockQuote , IfArm , { ElseIfArm } , [ ElseArm ] ;
IfArm = IfMarker , BranchBody ;
ElseIfArm = ElseIfMarker , BranchBody ;
ElseArm = ElseMarker , BranchBody ;
IfMarker = "`" , "if" , "`" , Condition ; (* two spans: keyword + condition *)
ElseIfMarker = "`" , "elseif" , "`" , Condition ;
ElseMarker = "`" , "else" , "`" ; (* no condition *)
Condition = "`" , Key , "?" , "`" ; (* see Conditions *)
BranchBody = { Block } ; (* any blocks, incl. a nested ControlBlock *)
BlockQuote is the Markdown blockquote container; each marker is its own paragraph, a
BranchBody is the blocks between one marker and the next (or the block's end), and a
nested ControlBlock is a nested > > blockquote inside a BranchBody. Condition,
Key, and the effect fragments are unchanged from their notes.
Architecture
Recognition happens in the transpiler, not in a desugar rule. A control block is a Markdown-level shape — a marker-headed blockquote with nested blocks — so it must be built where that structure is still in hand. (Contrast the Control Line, recognized in desugar because it needs jumps assembled first.) Two things change; the rest of the pipeline recurses into branch bodies.
flowchart LR
MD["Markdig QuoteBlock<br/>(nested > > already nested)"] --> CV["Converter →<br/>Markdown AST QuoteBlock"]
CV --> BB{"BlockBuilder:<br/>marker-headed quote?"}
BB -->|"yes"| CBB["ControlBlockBuilder<br/>validate + split markers"]
CBB --> CB["ControlBlock<br/>bodies recurse through BlockBuilder"]
BB -->|"no"| RAW["transparent wrapper<br/>(inner blocks in place)"]
- Markdown AST gains a structural
QuoteBlockblock: the converter maps a MarkdigQuoteBlockto it (holding its converted child blocks) instead of flattening it, so a nested> >is a nestedQuoteBlock. - The transpiler's
BlockBuilderdispatches aQuoteBlock: a non-marker quote is a transparent wrapper whose inner blocks are transpiled in place; a marker-headed quote is delegated toControlBlockBuilder. ControlBlockBuildervalidates and splits markers, builds eachBranch, and calls back into its owningBlockBuilderfor branch bodies. The shared recursion makes a nested marker-headed quote a nestedControlBlock.
The AST mirrors Choices/Choice:
internal sealed record ControlBlock(IReadOnlyList<Branch> Branches, SourceSpan Span)
: ScriptBlock(Span);
internal sealed record Branch(
Condition? Condition, IReadOnlyList<ScriptBlock> Body, SourceSpan Span)
: ScriptNode(Span), IConditional; // if/elseif carry a Condition; else is null
Because a Branch.Body is a list of ScriptBlock, every downstream pass reaches inside
it by recursing — the DialogueAstRewriter, the ScriptNodeExtensions traversal, the
report projection, and the validation rules each gain a ControlBlock arm. Each switch
throws on an unknown kind, so the compiler will not build until all handle it — the
completeness the Control Line note relied on.
Interfaces and responsibilities
| Component | Responsibility |
|---|---|
QuoteBlock (Markdown AST) |
Hold a blockquote's child blocks structurally. |
MarkdigToMarkdownAstConverter |
Map a Markdig QuoteBlock to the Markdown AST node. |
MarkerRecognition |
Read a paragraph as an `if`/`elseif` + condition, or `else`. |
BlockBuilder |
Dispatch a quote to control construction or transparently transpile its inner blocks. |
ControlBlockBuilder |
Validate and split markers, build branches, and recurse through BlockBuilder. |
ControlBlock / Branch (AST) |
Model the construct and its arms; carry spans and each arm's condition. |
DialogueAstRewriter |
Rewrite a ControlBlock and each branch body. |
ScriptNodeExtensions |
Enumerate a ControlBlock's branches and each branch's children. |
DialogueAstProjection |
Project a ControlBlock to a report node. |
| Validation rules | Treat a branch condition as a bound condition; reject scene headings inside branch bodies. |
Key design decisions
D1 — Recognize in the transpiler, from the blockquote structure
A control block is a Markdown structure (a marker-headed blockquote), so
ControlBlockBuilder builds it from the QuoteBlock passed by BlockBuilder, while that
structure is present, rather than reconstructing it later. This reuses the existing
"a container block becomes a grouped Dialogue block" path (a list → Choices) and keeps
the nested-quote tree Markdig already built.
D2 — The marker is the two-span form
`if` `cond?` — a bare keyword span plus the verbatim condition. Unquoted keys
make a one-span `if Rich?` ambiguous (it reads as a condition on the key
if Rich), so the keyword is kept a standalone span; the condition span is reused
untouched. See Chosen shape.
D3 — One connected blockquote, enforced; a severed chain is an error
Every arm shares one blockquote. Because the arms are coupled by unbroken >, an
accidental plain (non->) blank line severs the chain into separate blockquotes —
a break that is source-invisible. A severed `elseif`/`else` — one that
opens its own blockquote instead of continuing the `if`'s — is a strict error,
never silently re-paired, which turns the invisible break into a clear diagnostic.
D4 — ControlBlock / Branch mirror Choices / Choice
A ControlBlock holds ordered Branches; a Branch holds a Body of blocks and an
optional Condition, reusing IConditional. Nesting is a nested ControlBlock inside a
branch body — the same way a nested list nests inside a choice. No new traversal shape is
introduced; existing recursion reaches inside.
D5 — Editor support offsets the > authoring tax
CodeMirror's Markdown support continues the connected blockquote's > prefix when the
writer presses Enter. The compiler-projected editor seam (see
Compiler-Projected Editor Semantics)
highlights recognized `if` / `elseif` / `else` markers without a
second browser grammar. The raw preview stays readable without either aid.
D6 — A scene is a top-level unit; no heading inside a branch
SceneBuilder opens scenes only from top-level headings, so a heading nested in a
branch would build no scene and register no anchor — a silent no-op. Rather than support
a conditional scene (a conditional anchor would break deterministic jump resolution), a
scene heading inside a branch is a diagnostic. To choose a scene by condition, use a
conditional jump to a top-level scene; to make content conditional, keep it inline in the
branch. The same rule generalizes to a heading inside a choice body.
D7 — Separate every marker and utterance with a quoted blank line
Inside the blockquote each marker and each utterance is its own paragraph, so — as
everywhere in the language — a blank line separates them; here that blank line is
quoted (a bare > at the current depth). This is less a new rule than the
blockquote form of the existing one, with one sharp corollary: a quoted blank line
is what closes a nested branch before the outer branch resumes or an
elseif/else begins.
The corollary matters because of CommonMark lazy continuation: with no blank
line, a later line keeps continuing the open paragraph at the depth it started, so a
missing quoted blank line silently collapses a dedent — outer content, or an
elseif/else marker, is pulled into the nested branch — or fuses a marker with
its body. The damaging form, a marker swallowed into a nested branch, is detectable
and reported (Diagnostics); a content-only collapse cannot be
recovered once Markdig has merged it, so it is prevented by editor support
(D5) maintaining the quoted-blank
separators and documented with a before/after example in the guide.
Markdown interaction
A marker-headed blockquote becomes a ControlBlock; every other blockquote is a
transparent wrapper whose inner blocks are read in place as ordinary content, so a
plain > aside reads as narration rather than literal > aside text. Arms are joined by
a bare > line and nested by > > — both standard CommonMark, so no Markdig extension is
needed. The marker spans are ordinary inline code, so a stock preview renders them as
chips.
Diagnostics
Five grammar errors are reported during transpilation, before marker-only data is discarded from the semantic AST. One placement error belongs to validation; two existing checks recurse into branch bodies.
- Severed chain (
DLG1108) — a blockquote led by`elseif`or`else`with no connected`if`. Reported, never re-paired (D3). - Malformed marker order (
DLG1109) — an`elseif`after the`else`, a second`else`, or a marker the block builder cannot place. - Marker not standing alone (
DLG1110) — an`if`/`elseif`/`else`marker fused into a paragraph (mid-line, or with trailing speech) instead of alone on its line; the fix is a quoted blank line. Catches the nested lazy-continuation merge (D7). - Missing branch condition (
DLG1111) — an`if`or`elseif`marker lacks its required condition span. - Unexpected
elsecondition (DLG1112) — an`else`carries a condition; recovery keeps it as the unconditional fallback. - Orphan condition (
DLG1106, reused) — a branch's condition is bound, not an orphan, exactly as for a conditional line or control line. - Unreachable after a jump (reused) — the Progression Order check still applies to a jump inside a branch.
- Scene heading inside a branch (
DLG2015, validation) — a scene is a top-level unit; a heading nested in a branch is reported (D6).
Error and boundary cases
| Input | Reads as | Why |
|---|---|---|
a connected quote led by `if` |
a ControlBlock |
first child is an if marker |
a nested > > quote led by `if` |
a nested ControlBlock |
Markdig nests it; Build recurses |
`elseif`/`else` opening its own blockquote |
severed-chain error | not connected to an if |
a second `else`, or `elseif` after `else` |
marker-order error | branches must be if , elseif* , else? |
`if` / `elseif` without a condition |
missing-condition error (DLG1111) |
conditional branches require the second code span |
`else` followed by a condition |
unexpected-condition error (DLG1112) |
the fallback is unconditional |
| a scene heading inside a branch | scene-placement error (DLG2015) |
a scene is a top-level unit |
| a marker fused into a paragraph (no quoted blank line) | marker-standalone error (DLG1110) |
a marker must be alone on its line |
| a blockquote not led by a marker | transparent wrapper (inner blocks in place) | the construct claims only marker-headed quotes |
`if Rich?` (one span) |
a condition on the key if Rich |
not a marker — see D2 |
| an empty or effect-only branch body | valid | a branch may do nothing, or only act |
Testability
- Marker recognition —
`if`/`elseif`+ condition, and bare`else`; a one-span`if Rich?`stays a plain condition. - Transpile — a connected blockquote builds a
ControlBlockwith ordered branches; a nested> >builds a nestedControlBlock; a non-marker quote is a transparent wrapper. - Diagnostics — severed chains, malformed order, fused markers, missing conditions,
and an
elsecondition each report; a branch condition is not an orphan. - Spans — the block, each branch, and each condition preserve their source spans.
- Completeness — traversal, rewriting, validation, compiler integration, and
projection each cover
ControlBlock.
Alternatives not chosen
- Terminator marker (
`endif`) — clean to author and easy to reflow, but renders flat (nesting read by matchingif/endif) and needs a terminator keyword that dodges the#ENDclash; the connected blockquote grouping was preferred for its visible nesting and free boundary. - Separate (disconnected) blockquotes per arm — reads as independent asides and needs sibling-chain re-linking; the single connected blockquote reads as one construct (D3).
- One-span marker
`if Rich?`— collides with an unquoted key (D2). - Command-style marker
`If(Rich?)`— collides with the command form and inverts the "a command acts, a condition reads" distinction.