Square brackets [] split one line into three parts: content before [ will be printed in both choice and output, content between [ and ] is only printed in choice, and content after ] is output only.
A choice is used up after it is taken. + is sticky and can be taken again. A choice with no text is a fallback: the game takes it only when nothing else is available. * -> with lines under it is a fallback that still has content.
{test} in front of a choice shows it only when the test passes. Several tests on one choice must all pass for it to show up. Using A knot or stitch name in a test signifies how many times it has been seen; A non-zero value counts as true.
1
* {notparis}[Go to Paris]->paris
2
+ {paris}{notbored}[Return]->paris
3
* {seen_clue>3}[Arrest him]
Note that the test knot_name is true if any stitch inside that knot has been seen.
=== name === starts a knot. The closing equals signs are optional.
Content above any knot runs on its own. Knots do not, they have to be diverted to. Running out of content is treated as an error; -> DONE marks the intentional end of a thread, and -> END ends the whole story.
= stitch divides a knot into stitches. A divert can go into a stitch by calling-> knot.stitch. When diverting from inside the knot, using the stitch name is enough. Diverting to the knot enters its first stitch. Text above the stitches does not fall into the first stitch.
-> knot jumps immediately, and can sit in the middle of a sentence.
1
We hurried home ->and_then
2
3
=== and_then ===
4
as fast as we could.
A divert is a value that can be stored in a variable.
1
VARending=->bad_end
2
->ending
Knots and stitches can take parameters. A parameter that is itself a divert must be marked -> in the signature, or else read count is passed instead of the destination.
-> tunnel -> runs that knot, then continues on the next line. Inside the tunnel, ->-> comes back. ->-> elsewhere comes back out but goes somewhere else. Tunnels can be chained and nested: -> a -> b -> done.
A thread lets you stitch content from several places into one moment. Where a tunnel runs a block and comes back, a thread runs a block, keeps whatever choices it offered, and carries on with the next line.
<- knot forks the flow. Ink runs that knot until it runs out of content, collects every choice it found, then continues with the rest of the current knot. The player sees all the collected choices together, as if they had been written in one place.
1
=== room ===
2
The room is quiet.
3
<-chat_with_guard
4
<-look_around
5
->DONE
6
7
=== chat_with_guard ===
8
* [Greet the guard] "Evening."
9
->room
10
11
=== look_around ===
12
* [Search the shelves] Dust, mostly.
13
->room
This shows two choices, āGreet the guardā and āSearch the shelvesā, even though neither is written in room.
Once the player picks a choice, the other threads are dropped. The story is now on the normal flow of the thread that offered it. A choiceās content does not return to where the thread started, so give it somewhere to go, usually a divert back to the hub (-> room above).
WARNING
End a knot that only contains threads with -> DONE, which means āno more content here on purpose, the threads supply what comes nextā. -> END would end the entire story instead. You do not need -> DONE once a choice has been taken, because that is ordinary flow again.
WARNING
Threads share state. Variables and read counts are not forked, so changes made while a thread runs stay changed even if its choices are never picked.
Threads are most useful for adding the same choice to many places. Pass in a divert so the choice knows where to return.
1
=== kitchen ===
2
- (top)
3
<-review_notes(-> top)
4
* [Open the fridge]->top
5
6
=== review_notes(-> back) ===
7
+ [Review my notes]
8
Nothing new.
9
->back
TIP
If the shared block is plain content with no choices, a tunnel is simpler. Reach for a thread when the block brings its own choices, or conditions on them.
A weave lets choices rejoin without a knot per branch. - is a gather: once a choice has played out, the story drops down to the next gather at the same nesting level or shallower.
1
* [Open the door]
2
It creaks open.
3
* [Knock]
4
Nobody answers.
5
- Either way, you step inside.
Nesting is counted in marks, not indentation. * * is a choice inside the choice above it, and - - is a gather inside that same branch. Indent for your own eyes, the compiler does not care.
(name) labels a gather or a choice so it can be tested or diverted to. Put it before any {condition}. Inside the same weave, the bare name works; from outside, use the full path (knot.stitch.label).
1
* (greet) [Greet him]
2
- "Hmm."
3
* {greet}[Ask about his day]
4
- (leave) ->END
A labelled gather also gives you a loop:
1
- (ask)
2
* [Another question]->ask
3
+ [Enough]->END
TIP
Diverting to a choice label plays that choiceās output as though it had just been picked, and a once-only choice is used up.
Curly braces with | pick a different piece each time the line is reached. They only matter when the same text is visited more than once, so you will mostly use them in loops.
A plain {a|b|c} is a sequence: it moves along one piece per visit and stays on the last. A mark right after { changes the rule.
Mark
Behaviour
none
Move along, then stay on the last piece
&
Cycle, wrapping back to the start
!
Each piece once, then nothing
~
Shuffle
1
{I bought a coffee.|I bought another.|No money left.}
2
It was {&Monday|Tuesday|Wednesday}.
3
{!I laughed.|I smiled.}
4
{~Heads|Tails}
Pieces can be empty, nested, or end with a divert.
1
{!||Thenthelightswentout. ->dark}
WARNING
A choice line cannot start with {, because it looks like a condition. Put \ (backslash and a space) in front to make it text.
1
+ \ {&Go south|Head south}
The same behaviours exist as multiline blocks, which suit longer pieces. shuffle once plays every piece once and then stops. shuffle stopping shuffles all but the last piece, then stays on the last.
1
{ stopping:
2
- I entered.
3
- I entered again.
4
}
5
{ shuffle once:
6
- The sun was hot.
7
- It was a hot day.
8
}
{condition: text} prints the text only when the test passes. Add | for the else case.
VAR declares a global and can sit anywhere in the file. Its starting value decides its type: number, string, divert, or list. CONST is the same but never changes. ~ temp is scratch space that disappears when the story leaves that knot or stitch.
1
VARgold=0
2
VARname="Emilia"
3
CONSTLOBBY=1
4
~temproll=RANDOM(1, 6)
A line starting with ~ is logic, not text, so it prints nothing.
1
~gold=gold+7
2
~name="Jean"
Arithmetic is + - * / and % (or mod), plus POW(base, exp). RANDOM(min, max) includes both ends.
WARNING
Types follow the inputs. Integer division stays an integer, so 2 / 3 is 0. Use FLOAT() first if you want a decimal result.
1
~x=2/3// 0
2
{INT(-4.8)}// -4, toward zero
3
{FLOOR(-4.8)}// -5, always down
== and != compare strings, and ? tests whether one string contains another: "Yes, please" ? "ease".
To print a value, put it in braces.
1
My name is {name}. I have {gold} coins.
WARNING
A string can hold ink, such as "{~red|blue}", but it is evaluated once and then sticks. Assign it with ~ during the story. Putting it in the VAR line is not allowed, because it would be evaluated before the story starts.
A function is a knot declared with function. It can print, call other functions, recurse, and ~ return a value. It cannot contain stitches, diverts, or choices.
1
=== functionlerp(a, b, k) ===
2
~return ((b-a) *k) +a
3
4
~x=lerp(2, 8, 0.3)
5
He looks {describe(health)}.
Always call with parentheses, even with no arguments. Inline, whatever the function returns is printed into the sentence, along with anything it prints itself.
ref hands in the variable itself, not a copy, so the function can change it.
1
=== functionalter(ref x, k) ===
2
~x=x+k
3
4
~alter(gold, 7)
Built-ins, always available:
1
{CHOICE_COUNT()}// choices gathered so far this chunk
2
{TURNS()}// turns since the start
3
{TURNS_SINCE(->sleep)}// 0 this chunk, -1 if never
4
~SEED_RANDOM(235) // fix randomness
TIP
TURNS_SINCE takes a divert (-> sleep), not a bare name. A bare name would give you its read count.
EXTERNAL playSound(name) declares a function the game provides. If the game has not bound it, an ink function with the same name runs instead, which is handy for testing.
A LIST is an ordered set of names plus a variable that holds some of them. It is not an array. Names in parentheses start switched on. The first name is worth 1, and ++ / -- step along the order.
1
LISTHeat=cold, (warm), hot
2
3
~Heat=hot
4
~Heat--
5
The kettle is {Heat}.
You can set your own numbers. LIST_VALUE gets a nameās number, and Family(n) goes back the other way.
1
LISTPrimes=two=2, three, five=5
2
3
{LIST_VALUE(three)}// 3
4
~tempn=Primes(2) // two
Two lists may share a name. When that happens, qualify it with the list: colours.blue.
A list variable can hold several names at once, like an in/out board. () is empty. += and -= switch names on and off, and doing either twice is harmless.
1
LISTIn= (Adams), Bernard, Eamonn
2
~In= (Adams, Bernard)
3
~In+=Eamonn
4
~In-=Adams
5
~In= ()
WARNING
= replaces the whole set. To change one name without touching the rest, use += and -=.
Test them with == (exactly the same set), has / ? (contains all of these), and hasnt / !?. A list on its own is true when it is not empty.
1
{In== (Adams, Bernard)}
2
{InhasAdams}
3
{In ? (Adams, Bernard)}
4
{In: The surgery is open.|Everyone has gone home.}
WARNING
hasnt (a, b) means ānot both of them are hereā, not āneither is hereā. And list ? () is always false, even for an empty list.
Queries:
1
{LIST_COUNT(In)}
2
{LIST_MIN(In)}
3
{LIST_MAX(In)}
4
{LIST_RANDOM(In)}
5
{LIST_ALL(Adams)}// every name in that family
6
~In=LIST_INVERT(In)
^ gives the overlap of two lists. Comparing with > means every value on the left is above every value on the right.
WARNING
>= is not āgreater or equalā plus āequalā. It has its own overlap rule, so skip it unless you have worked out what you want.
A struct bundles related fields and methods, so a character is one thing instead of met_estelle, met_johnson, and so on.
Declare fields first (VAR, REFVAR), then methods and stitches. End the body with === (three or more equals signs) before any top-level VAR. The next knot or struct title ends it too.
1
=== structCharacter ===
2
VARname="anonymous"
3
VARnerves=1
4
= functionIntroduce() =
5
My name is {self.name}.
6
~return
7
===
WARNING
Without the closing ===, a VAR right after the struct is read as another field of it.
The type name is also its default instance, so Character.name works straight away. VAR hero: Character makes your own copy. Copies are deep: changing one never changes another.
1
{Character.name}
2
~Character.nerves++
3
4
VARhero:Character={name="Oswald", nerves=2, }
5
VARguest:Character=hero
6
~guest.name="Visitor"// hero.name is still Oswald
A { field = value, } block patches fields while copying. Trailing commas and nesting are fine.
Inside a method, reach members through self. A bare name always means a global, never a field.
1
~hero.Introduce()
A stitch declared without function is narrative. You divert or tunnel into it, and you do not call it. A method is the opposite.
1
=== structScene ===
2
VARtitle="..."
3
= intro(who)
4
{self.title}: Hello, {who}!
5
->->
6
===
7
8
->Scene.intro("traveller")->
TIP
One quick check: if it prints story and offers choices, make it a stitch and divert to it. If it computes something, make it a function and call it with ~.
= operator ⦠= lets a struct respond to symbols. You can overload + - * / %, unary -, the comparisons, [] / []= (for obj[key]), and () (to call the object like a function). Annotate struct parameters with their type.
1
=== structVec2 ===
2
VARx=0
3
VARy=0
4
= operator+(other: Vec2) =
5
~tempr:Vec2
6
~r.x=self.x+other.x
7
~r.y=self.y+other.y
8
~returnr
9
===
+= and ++ are not separate operators. They use + and assign the result back. You cannot overload and, or, not, is, has, or ^. Without your own [], obj[key] just reads obj.{key}.
List base types after a colon. A field redeclared in the child changes the inherited default, and a redeclared method replaces the inherited one. base.Method() calls the version you replaced, and only works inside the override.
1
=== structOswald: Character ===
2
VARname="Oswald"
3
= functionIntroduce() =
4
~base.Introduce()
5
~return
6
===
7
8
VARsomeone:Character=Oswald
9
~someone.Introduce()
TIP
A variable typed as the base type still runs the real typeās method. someone is a Character, but someone.Introduce() uses Oswaldās version.
is / isnt ask about the real type, including its bases. as casts softly, and a failed cast gives none. is Type name tests and binds in one step.
1
{OswaldisCharacter}
2
{(someoneasOswald).name}
3
{someoneisOswaldo:{o.name}}
WARNING
Reading a field or calling a method on none is a runtime error, so test with is first.
Assigning a struct with VAR always copies. If you want two names for the same instance, use REFVAR: it points at a global instance (or none) and does not copy.
1
REFVARhere:Character=hero
2
~here.name="Oswald"// hero.name changes too
3
~here=none
WARNING
A REFVAR can only point at a global, never at a temp.
The same applies to parameters. They are copies unless you write ref.
A dynamic starts out like a struct, but a live instance can gain and lose slots while the story runs. Be careful with these. They are easy to misuse, and a mistake only shows up at runtime.
1
=== dynamicBag ===
2
VARscore=0
3
===
4
VARbag:Bag
5
~bag.extra=5// creates the slot
6
{baghasextra}
7
~bag.extra= [] // removes it
WARNING
Reading or calling a slot that does not exist is a runtime error. Check first with has / hasnt.
To delete a slot, assign []. Plain () is an empty list, not a deletion.
Slots store copies by default. Put ref on the right to store a reference to a global instead. VAR scratch: dynamic gives you an empty one.
1
~bag.item=hero// copy
2
~bag.item=refhero// reference
A dynamic may inherit from a struct or another dynamic, but a struct can never inherit a dynamic. {x is dynamic} and {x is struct} test which kind you have.
for and while go inside the same braces as conditions. If the closing } is on the same line it is a one-liner, otherwise it is a block. ~ break and ~ continue only affect the innermost loop.
1
{foriinrange(0, 3):{i}}
2
3
{while n > 0:
4
~n--
5
}
range(start, stop) includes start and stops before stop. An optional third argument is the step. range only works in a for header.
Loop over a struct or dynamic and you walk its fields by default. With two names you get key, value, with one you get just the item. fields, methods, stitches, and all choose which slots you walk.
1
{forkey, valueinfields(bag):
2
{key} = {value}
3
}
4
{foritemininventory:{item}}
TIP
The slots are fixed when the loop starts, and walked in sorted order, so adding or removing slots mid-loop does not change which rounds run. Outside a loop, those four functions return the names as a list.
Directives run before the story is parsed, and every one starts with # at the beginning of a line. They work like Cās #if and #pragma. A directive line is replaced by a blank line, so error messages still point at the right line.
#IF keeps a block only when its symbol is switched on. Symbols are chosen by the tool that compiles the story. Inky switches on INKY, so the same file can behave differently in the editor and in your game.
1
#IFINKY
2
~debug=true
3
#ELIFPREVIEW&&!SHIPPING
4
~debug=true
5
#ELSE
6
~debug=false
7
#ENDIF
The condition is a symbol name, combined with !, &&, || and parentheses. A symbol that was not switched on counts as false. Only the first matching branch is kept, and the rest is blanked out.
WARNING
Groups cannot be nested. A second #IF before the first #ENDIF is an error, and so is a missing #ENDIF or an empty condition. These are reported rather than ignored, so excluded text never slips into the story.
Plain Ink lets you call game code through EXTERNAL. Sumi can also compile your C# next to the story, so Inky and Sumy can run the real bindings instead of a stand-in.
#PRAGMA external_source takes a .cs file, or a folder (its top-level .cs files are all used). Relative paths start from the folder of the story file. Quote paths that contain spaces.
1
#PRAGMAexternal_source"game/Hooks.cs"
2
#PRAGMAexternal_source ./hooks
The files are compiled in memory with Roslyn against the Ink runtime that is already loaded, and compiled output is cached by content, so unchanged files are not rebuilt.
Write a public, non-abstract class with a parameterless constructor. One instance is created per play session. Every method below is optional, but the signature has to match exactly.
1
usingInk.Runtime;
2
3
publicclassHooks
4
{
5
intgold;
6
7
// Called once before play: register your externals.
Serialize and Deserialize go together. If only one exists, the game-state view shows a signature guide instead of an editor.
Commands are typed as a line such as give 50. The first word picks the command, and the rest are its arguments. Double quotes group words into one argument. With #PRAGMA command_identifier, a line of story text that starts with that prefix is run the same way instead of being shown.
A missing file, a compile error, or a throwing Bind is a warning, not a failure, and the story still plays.
The Sumy web player needs the browser C# runtime. When it is not available, external_source is not bound and the JavaScript compiler plays the story with a warning.
Treat an external that changes game state as an action and a pure calculation as a pure function. See lookaheadSafe in Running Your Ink.
Story text can be any Unicode. Names of variables, knots, and the like may use Latin extensions and Arabic, Armenian, Cyrillic, Greek, and Hebrew letters. Save the file as UTF-8.