Compiles Yarn Spinner dialogue into a Yarnspinnex.Script (plain data, safe to cache) and runs it one event at a time against a Yarnspinnex.Cursor (plain data, safe to persist). No processes.
def deps do
[{:yarnspinnex, github: "atavistock/yarnspinnex"}]
endQuick start
title: Start
---
Sally: Hi there. How's it going?
-> Great, thanks!
Sally: Glad to hear it.
-> Could be better...
Sally: Aw, I'm sorry.
==={:ok, script} = Yarnspinnex.compile_string(source)
cursor = Yarnspinnex.start(script, gold: 10)
case Yarnspinnex.step(script, cursor, context) do
{:line, %Yarnspinnex.Line{speaker: speaker, text: text}, cursor} -> ...
{:command, name, args, cursor} -> ...
{:options, options, cursor} -> {:ok, cursor} = Yarnspinnex.choose(script, cursor, index, context)
{:done, cursor} -> cursor.vars
endstart/2 enters the Start node, or the first node if there is none; enter/3 enters any node by title.
Syntax
Sally: Your score is {$score}. #line:0a1b2c #shout
<<set $score += 5>>
<<declare $score = 0 as number>>
<<if $score > 10>>
Sally: Impressive!
<<elseif $score > 0>>
Sally: Not bad.
<<else>>
Sally: Ouch.
<<endif>>
-> Buy a pie <<if $gold >= 10>>
<<spend_gold 10>>
-> Leave
<<jump NextScene>>
[[NextScene]]
[[Say goodbye|NextScene]]
<<stop>>- Speaker: everything before the first
:. Write\:for a literal colon that does not start a speaker. #line:tag: becomesidon theLineorOption, the usual localization key. Markup such as[b]passes through untouched.- Comments:
//starts one, except inside quotes. - Assignment:
<<set>>accepts=orto, plus+= -= *= /= %=. Anas typeannotation is accepted but not enforced. - Option conditions: a trailing
<<if>>setsenabled?but keeps the option in the list. - Option bodies: nested by indentation, after which the dialogue continues below the option group.
- Jumps: a jump never returns, because there is no call stack. Targets must be literal node names, and titles must be valid jump targets (letters, digits, underscores).
- Commands: any other
<<...>>is a command. Each argument is a bare number (a number), a quoted string,{expr}(evaluated), or any other bare word (passed as text, never evaluated). A bare$nameis a compile error; write{$name}. - Padding:
<< set $x to 1 >>parses here, but the reference compiler may reject it.
Expressions:
- Operators:
+ - * / %,== != < > <= >=(alsois eq neq lt gt lte gte), andand or not xor(also&& || ! ^).+also joins strings. - Built-in functions:
random random_range dice round round_places floor ceil inc dec int decimal string number bool. - Number rendering:
{10 / 2}renders5and{5 / 2}renders2.5. - Types: ordering comparisons need numbers. Conditions must be
true,false, ornil(an unset variable counts as false), so<<if $count>>with a number raises; write$count > 0orbool($count).
Variables, context, functions, commands
- Variables: the dialogue's own memory, in
cursor.vars. Written by<<set>>and persisted with the cursor; keep them scalar. - Context: live host state, passed to every
step/3andchoose/4. The script can read it but never write it, and it is never persisted. Context wins when a name is in both. - Functions: host-declared reads over context, which can take arguments.
- Commands: every write or effect. The host applies them through its own domain code.
$player in a script is player in Elixir. Vars and context may be keyword lists or maps with atom or string keys. Nested values must be maps, and lookup tries the string key first, then the atom key.
Compile options:
Yarnspinnex.compile_string(source,
functions: MyGame.DialogueFunctions,
context: [:player, :time_of_day],
variables: [:met_baker],
strict: true
):functions: aYarnspinnex.HostFunctionsmodule. Every call is checked againstdeclared/0by name and arity.:context: names the host owns. Assigning to one of them is a compile error.:variables: names that arrive invarsfrom outside this script.:strict: every$namethat is read must be set or declared in the script, or listed in:contextor:variables. This catches misspellings at compile time.
defmodule MyGame.DialogueFunctions do
@behaviour Yarnspinnex.HostFunctions
@impl true
def declared, do: [{"gold", 0}, {"visited", 1}]
@impl true
def call("gold", [], context), do: context.player.gold
def call("visited", [node], context), do: MyGame.Dialogue.visited?(context.player, node)
endcall/3 gets the context exactly as passed to step/3.
Projections
Scripts read scalars, plain maps of scalars, or a Yarnspinnex.Projection. Reading a field on a struct, comparing a non-scalar, or rendering one in a line raises, so a domain object passed by mistake fails loudly instead of comparing false. Put anything that needs an argument or a lookup in a host function.
context =
Yarnspinnex.Projection.new(%{
player: Yarnspinnex.Projection.new(%{class: "mage", level: 5}, source: player),
time_of_day: "night"
})The script sees fields, while host functions also see source. Fields whose names start with _ are unreadable, which covers __struct__ and __meta__. An unknown field reads as nil, and fields are read-only.
Errors
Yarnspinnex.CompileError, returned bycompile_string/2: malformed directives, duplicate or invalid titles, unknown jump targets, unknown functions or wrong arity, writes to context names, and (when strict) unknown variables.Yarnspinnex.RuntimeError, raised bystep/3andchoose/4: type errors, division by zero, host function failures, a cursor that does not fit the script, and jump loops that never produce an event.ArgumentError, raised for caller mistakes: bad options, a module that does not implementHostFunctions, a struct passed as vars or context.
The two Yarnspinnex errors carry node and line, and their message reads like line 12 in node "Baker": cannot apply > to nil and 5.
Persisting
Usually you only need to persist cursor.vars and start each conversation at whichever node fits, branching on state:
title: Baker
---
<<if visited("Baker_Intro")>>
<<jump Baker_Waiting>>
<<else>>
<<jump Baker_Intro>>
<<endif>>
===To resume mid-conversation across requests, store the cursor itself:
save!(id, :erlang.term_to_binary(cursor))
cursor = :erlang.binary_to_term(load!(id), [:safe])
with :ok <- Yarnspinnex.check_cursor(script, cursor),
{:ok, cursor} <- Yarnspinnex.choose(script, cursor, index, context) do
Yarnspinnex.step(script, cursor, context)
end- Nothing is replayed: each statement runs once, however many requests the conversation spans.
- Bad answers:
choose/4returns{:error, :invalid_option}or{:error, :option_disabled}, and nothing runs. - Fingerprints: a cursor carries its node's fingerprint, and changing that node makes
check_cursor/2return{:error, :stale_script}. Edits to other nodes, comments, and blank lines leave it valid.
Diagrams
Yarnspinnex.Mermaid.node_graph/1 draws nodes and jumps, and flowchart/2 draws every statement. Pass nodes: [...] to draw only some nodes.
File.write!("dialogue.mmd", Yarnspinnex.Mermaid.flowchart(script))Not supported
- Statements:
<<detour>>,<<return>>,<<once>>. - Yarn Spinner 3 features: enums, smart variables, node groups.
- Computed jumps:
<<jump $target>>is a compile error. - Visit tracking:
visited()andvisited_count()are not built in; provide them as host functions. - Markup: passed through, not parsed.
- Field writes:
<<set $player.gold = 20>>is invalid; emit a command instead.