Dialogue Graph
Note
Status: implemented. The last
pipeline stage: it lowers the
semantic model into an immutable, directed graph of
nodes and typed edges with a scene-region overlay, carried on a
CompilationSuccess.
Walking the graph is the runtime's job; reachability and cycle diagnostics, a
#START entry, and cross-file node ids are not built.
Table of contents
- Goal and scope
- Ubiquitous language
- The intermediate representation
- Lowering
- Key design decisions
- Error and boundary cases
- Testability
Goal and scope
The semantic model keeps the shape of a document: a tree of scenes owning blocks. A runtime needs a flow: from here, where can control go? The same script takes both shapes:
Scene tree (from analysis — naming and scope):
flowchart TB
Root(["root scene"]) --> C["The Crossroads"]
C --> S["The Signpost"]
Root --> M["The Market"]
Dialogue graph (this stage — flow, with a scene-region overlay):
flowchart LR
subgraph rC["region · The Crossroads"]
c0["Guide: Which way?"]
subgraph rS["region · The Signpost"]
s0["Guide: Three roads."]
end
end
subgraph rM["region · The Market"]
m0["Merchant: Apples!"]
end
c0 -->|succession| s0 -->|succession| m0 -->|succession| E(["End"])
The regions still nest, but the flow reads straight through them in document order. Jumps add cross-region and cyclic edges, so the result is a graph, not a tree. How each construct flows is fixed by Progression Order.
Ubiquitous language
| Term | Meaning |
|---|---|
| Node | One playable block — a line, a control line, a choice, a random choice, a branch — or the single End node. |
| Edge | A directed connection to a target node id, of a specific kind. |
| Succession | Fall-through to the next block in document order. |
| Divert | The edge a jump lowers to. It does not return. |
| Option | One arm of a choice; a random option also carries a weight. |
| Branch edge | One ordered arm of a block control; the first whose condition holds is taken. |
| Condition | The AST Condition, opaque to the core. On an edge it withholds a route; on a node it withholds the node's content. |
| Effect | A game call (GameCall) a node runs when it plays. |
| Region | A named grouping overlaid on the flat graph — a scene. Its own nodes are held directly; subregions nest. |
| Entry block | The block a scene is entered at: its first block, or the next block in reading order when it owns none. |
| Draft | The mutable graph under construction; Freeze validates it into the immutable graph. |
The intermediate representation
readonly record struct NodeId(int Value); // an opaque handle, not a list index
readonly record struct RegionId(int Value);
sealed class DialogueGraph // Node(NodeId) looks up through an id-keyed dictionary
{
IReadOnlyList<DialogueNode> Nodes; NodeId Entry; NodeId End; RegionTree Regions;
}
// ── Nodes: one per block. Payload reuses the semantic model and the AST.
abstract record DialogueNode(NodeId Id, SourceSpan Span, IReadOnlyList<Edge> Out);
sealed record LineNode(Id, Span, SpeakerSymbol Speaker, IReadOnlyList<InlineFragment> Speech,
Out, Condition? Condition); // Effects: the GameCalls in Speech
sealed record ControlNode(Id, Span, IReadOnlyList<GameCall> Effects, Out, Condition? Condition);
sealed record ChoiceNode(Id, Span, bool IsOrdered, Out); // Out: OptionEdges
sealed record RandomChoiceNode(Id, Span, Out); // Out: RandomOptionEdges
sealed record BranchNode(Id, Span, Out); // Out: BranchEdges
sealed record EndNode(Id, Span);
// ── Edges: each names its target by id.
abstract record Edge(NodeId Target);
sealed record SuccessionEdge(Target);
sealed record DivertEdge(Target, IReadOnlyList<InlineFragment> Label, Condition? Condition);
sealed record OptionEdge(Target, IReadOnlyList<InlineFragment> Label, Condition? Condition);
sealed record RandomOptionEdge(Target, ChoiceWeight Weight, Condition? Condition);
sealed record BranchEdge(Target, int Order, Condition? Condition);
// ── Overlay: metadata over the flat graph, not part of its topology.
sealed record RegionTree(IReadOnlyList<Region> Roots);
abstract record Region(RegionId Id, NodeId Entry, NodeId Exit,
IReadOnlySet<NodeId> OwnNodes, IReadOnlyList<Region> Subregions);
sealed record SceneRegion(…, IReadOnlyList<InlineFragment> Label, string Anchor) : Region;
LineNode and ControlNode implement IConditionalNode; the conditional edges
implement IConditionalEdge. All types are internal and live in DialogueDown.Graph.
Lowering
DialogueGraphBuilder runs a list of passes over one GraphDraft, then freezes it.
DialogueGraphBuilderFactory composes the default list:
flowchart LR
SM["SemanticModel"] --> C["GraphBuildContext<br/>document order · entry blocks"]
C --> P1["NodeCreationPass"] --> P2["DivertPass"] --> P3["ChoicePass"] --> P4["BranchPass"] --> P5["SuccessionPass"] --> P6["RegionPass"]
P6 --> F["GraphDraft.Freeze()"] --> G["DialogueGraph"]
NodeCreationPassadds a node per block, then the End node. ASceneHeadingnames a scene and plays nothing, so it gets no node.DivertPassturns each jump resolution into a divert: aSceneJumpto the target scene's entry node, aTerminalJumpto End.ChoicePassandBranchPassfan out option and branch edges; each arm's body rejoins the block's continuation.SuccessionPassadds fall-through to every node that does not already leave unconditionally; the last block falls through to End.RegionPassprojects the scene tree intoSceneRegions.
INodeIdBuilder assigns ids as nodes are added; IndexNodeIdBuilder numbers them by
arrival, and a source-derived strategy can replace it per build through
INodeIdBuilderFactory.
flowchart LR
subgraph Crossroads["Scene: The Crossroads"]
n0["n0 Line: Which way?"] -->|succession| n1["n1 Control: => the-market"]
end
subgraph Poisoned["Scene: Poisoned"]
n2["n2 Line: You drank it…"]
end
subgraph Market["Scene: The Market"]
n5["n5 Line: Fresh apples!"]
end
n1 -->|divert| n5
n2 -->|"divert (#END)"| E(["End"])
Key design decisions
D1 — A hand-rolled IR, not a graph library
The graph sits with compiler control-flow graphs (Roslyn's ControlFlowGraph, LLVM):
a flat list of blocks, typed successor edges, and a separate region hierarchy. Story
engines such as Ink walk a container tree instead, which does not fit DialogueDown's
arbitrary cross-scene jumps. QuikGraph, the maintained .NET option, has no
hierarchical grouping and is mutable by default; the algorithms a dialogue graph
needs are small. If deeper analysis is wanted, QuikGraph can be an optional adapter
over this IR, never a core dependency.
D2 — Grouping is an overlay, not topology
Nodes and edges form one flat graph, and a RegionTree says which nodes belong to
which scene — matching the language's rule that scene nesting is scope, not flow. A
jump into the middle of another scene stays a plain edge instead of piercing a
container. A grouping's kind is its type, so a future file grouping is a new
subclass, not a nullable column.
D3 — Only an addressable grouping earns a region
A region exists when something outside can name it and enter it — a scene by its anchor. A block control's branch has no name and is entered only from its own block; its extent is recoverable from the branch edges and the continuation, so grouping it is a query over the graph, and there is no branch region.
D4 — A pass pipeline over a mutable draft
Each pass owns one concern, so a new construct adds a pass, as desugar adds a rule. Every pass that gives a node its own route — diverts, choices, branches — runs before succession, so fall-through is withheld from a node that already leaves rather than added and removed.
D5 — A condition binds at the level it is written
A condition on a jump rides its divert, and the node keeps its fall-through as the
path taken when the condition fails. A condition on a block withholds the block's
content, so it sits on the node (IConditionalNode). Either way the host decides the
condition at play time, and "force this path" in a debugger is one action over a
node's Out list at a real, source-mapped node.
D6 — The node payload reuses the AST and the semantic model
A line's fragments split three ways, each kept as the type that already models it:
| Fragment | Becomes | Kept as |
|---|---|---|
Text, StyledText, Image, Link, LineBreak |
the node's speech | AST fragments |
GameCall |
an effect | the AST GameCall |
Jump, Condition |
a divert edge and its condition | lifted out of the payload |
The speaker is the resolved SpeakerSymbol; a weight is the AST ChoiceWeight.
D7 — Opaque node ids
Edges hold ids, never object references, so cycles are ordinary edges and the graph
is built without back-references. Callers resolve a node through
DialogueGraph.Node(id), so an id can become a source-derived, stable value for
incremental compilation without touching callers. Random UUIDs would add uniqueness
without that stability and make structural tests nondeterministic.
D8 — Scene entry is a semantic-layer rule
Which block a scene is entered at is the same fall-through idea as document order, so
it lives beside it as Scene.EntryBlocks and is tested without building a graph.
Error and boundary cases
| Case | Behavior |
|---|---|
| Empty document | A graph whose Entry is the End node. |
| Heading-only scene | No region (it owns no nodes); a divert to it lands on the next block, or End. |
| Content before the first heading | Nodes in no region; the root scene has no heading. |
| Content after an unconditional divert | Not wired; analysis already reported DLG1003. |
UnresolvedJump / FileScopedJump |
No divert; the line reads on. Already reported by analysis. |
SceneHeading inside a branch or option |
Passed over; already reported (DLG2015). |
| A block kind with no lowering | NotSupportedException, so a new construct cannot yield a silently wrong graph. |
| Any error in the compile | No graph; the result is a CompilationFailure. |
| A jump back to an earlier scene | An edge to an earlier id. |
Testability
Buildis a pure function of the semantic model: a test compiles a small script through the pipeline and asserts nodes, edges, and regions.- Each pass is tested alone through a helper that runs a chosen pass chain over a fresh draft; the builder is tested for pass order and per-build isolation.
Scene.DocumentOrderandScene.EntryBlocksare tested directly.