4059 words
20 minutes
Sumi Cheat Sheet
2026-10-16

Plain Text#

Plain Text in Writing with Sumi

Hello, world!
Hello?

Comments#

Comments in Writing with Sumi

Single Line Comment#

Single Line Comment in Writing with Sumi

Use //

// this is a single line comment!

Multi-line Comment#

Multi-line Comment in Writing with Sumi

Use /* ... */

/*
This is a multi-line comment!
*/

TODOs#

TODOs in Writing with Sumi

Use TODO:

TODO: this is a todo!

Tags#

Tags in Writing with Sumi

Use # after a line of text

The door makes a funny sound. #sound:squeak
// multiple tags are OK
The door makes a funny sound. #sound:squeak #scene:door
// tags can be dynamic, contain any logic that eval to string
VAR sfx = squeak
The door makes a funny sound. #sound:{sfx}

Glue#

Glue in Writing with Sumi

<> glues the next line to this one, even across a divert. The content would be concatenated and printed in the same continuation.

We hurried home <>
to Savile Row.

Choices#

Choices in Writing with Sumi

* offers a choice, then continues into the lines under it. The choice text is printed again in the output.

* Hello back!
Nice to hear from you!

Content Visibility in Choices#

Content Visibility in Choices in Writing with Sumi

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.

* "I am tired[."]," I said.
* [Say nothing]
You stay quiet.

Sticky Choices#

Sticky Choices in Writing with Sumi

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.

+ [Again] -> loop
* [Leave] -> END
* ->
Nothing left to try.
-> END

Conditional Choices#

Conditional Choices in Writing with Sumi

{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.

* {not paris} [Go to Paris] -> paris
+ {paris} {not bored} [Return] -> paris
* {seen_clue > 3} [Arrest him]

Note that the test knot_name is true if any stitch inside that knot has been seen.

Knots#

Knots in Writing with Sumi

=== 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.

-> start
=== start ===
Hello.
-> END

Stitches#

Stitches in Writing with Sumi

= 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.

=== train ===
Where to?
* [Third class] -> third
= first
Settled in.
= third
Standing room.

Flow#

Flow in Writing with Sumi

Divert#

Divert in Writing with Sumi

-> knot jumps immediately, and can sit in the middle of a sentence.

We hurried home -> and_then
=== and_then ===
as fast as we could.

A divert is a value that can be stored in a variable.

VAR ending = -> bad_end
-> 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.

-> sleep(-> wake)
=== sleep(-> then) ===
You sleep.
-> then

Tunnel#

Tunnel in Writing with Sumi

-> 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.

-> aside ->
Back to the scene.
=== aside ===
A brief detour.
->->

Threads#

Threads in Writing with Sumi

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.

=== room ===
The room is quiet.
<- chat_with_guard
<- look_around
-> DONE
=== chat_with_guard ===
* [Greet the guard] "Evening."
-> room
=== look_around ===
* [Search the shelves] Dust, mostly.
-> 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.

=== kitchen ===
- (top)
<- review_notes(-> top)
* [Open the fridge] -> top
=== review_notes(-> back) ===
+ [Review my notes]
Nothing new.
-> 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.

Weave#

Weave in Writing with Sumi

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.

* [Open the door]
It creaks open.
* [Knock]
Nobody answers.
- 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.

- Murder or accident?
* "Murder!"
Who?
* * "The butler!"
* * "Myself!"
- - "You must be joking."
* "Accident!"
- She closes the book.
WARNING

Gathers cannot live inside { } conditional blocks. Choices can, gathers cannot.

(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).

* (greet) [Greet him]
- "Hmm."
* {greet} [Ask about his day]
- (leave) -> END

A labelled gather also gives you a loop:

- (ask)
* [Another question] -> ask
+ [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.

Varying text#

Varying text in Writing with Sumi

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.

MarkBehaviour
noneMove along, then stay on the last piece
&Cycle, wrapping back to the start
!Each piece once, then nothing
~Shuffle
{I bought a coffee.|I bought another.|No money left.}
It was {&Monday|Tuesday|Wednesday}.
{!I laughed.|I smiled.}
{~Heads|Tails}

Pieces can be empty, nested, or end with a divert.

{!||Then the lights went out. -> 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.

+ \ {&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.

{ stopping:
- I entered.
- I entered again.
}
{ shuffle once:
- The sun was hot.
- It was a hot day.
}

{condition: text} prints the text only when the test passes. Add | for the else case.

{met: "I saw him." | "I missed him."}
His name was {learned_name: Franz|a secret}.

Variables#

Variables in Writing with Sumi

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.

VAR gold = 0
VAR name = "Emilia"
CONST LOBBY = 1
~ temp roll = RANDOM(1, 6)

A line starting with ~ is logic, not text, so it prints nothing.

~ gold = gold + 7
~ 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.

~ x = 2 / 3 // 0
{INT(-4.8)} // -4, toward zero
{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.

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.

~ colour = "{~red|blue}"

Conditions#

Conditions in Writing with Sumi

A multiline { } is an if / else if / else chain. Each - line is a test, and - else: catches everything else.

{ gold > 0:
~ gold = gold - 1
- else:
You are broke.
}

If the { is followed by a value instead of a test, each branch is compared against it, which works like a switch.

{ roll:
- 1: one
- 2: two
- else: more
}

These blocks can hold story text and choices, but not gathers. Combine tests with and / && and or / ||. There is no xor.

Functions#

Functions in Writing with Sumi

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.

=== function lerp(a, b, k) ===
~ return ((b - a) * k) + a
~ x = lerp(2, 8, 0.3)
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.

=== function alter(ref x, k) ===
~ x = x + k
~ alter(gold, 7)

Built-ins, always available:

{CHOICE_COUNT()} // choices gathered so far this chunk
{TURNS()} // turns since the start
{TURNS_SINCE(-> sleep)} // 0 this chunk, -1 if never
~ 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.

Lists#

Lists in Writing with Sumi

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.

LIST Heat = cold, (warm), hot
~ Heat = hot
~ Heat--
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.

LIST Primes = two = 2, three, five = 5
{LIST_VALUE(three)} // 3
~ temp n = Primes(2) // two

Two lists may share a name. When that happens, qualify it with the list: colours.blue.

Holding more than one value#

Holding more than one value in Writing with Sumi

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.

LIST In = (Adams), Bernard, Eamonn
~ In = (Adams, Bernard)
~ In += Eamonn
~ In -= Adams
~ 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.

{In == (Adams, Bernard)}
{In has Adams}
{In ? (Adams, Bernard)}
{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:

{LIST_COUNT(In)}
{LIST_MIN(In)}
{LIST_MAX(In)}
{LIST_RANDOM(In)}
{LIST_ALL(Adams)} // every name in that family
~ 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.

Mixing lists#

Mixing lists in Writing with Sumi

One variable can hold names from different lists, so one value can carry several independent properties.

LIST OnOff = on, off
LIST Temp = cold, hot
VAR kettle = (off, cold)
~ kettle -= LIST_ALL(on)
~ kettle += on

To change one property, remove every name of that family first, then add the new one, as above. Do not use =.

Structs (Sumi)#

Structs (Sumi) in Writing with Sumi

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.

=== struct Character ===
VAR name = "anonymous"
VAR nerves = 1
= function Introduce() =
My name is {self.name}.
~ return
===
WARNING

Without the closing ===, a VAR right after the struct is read as another field of it.

Instances#

Instances in Writing with Sumi

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.

{Character.name}
~ Character.nerves++
VAR hero: Character = { name = "Oswald", nerves = 2, }
VAR guest: Character = hero
~ guest.name = "Visitor" // hero.name is still Oswald

A { field = value, } block patches fields while copying. Trailing commas and nesting are fine.

Methods and stitches#

Methods and stitches in Writing with Sumi

Inside a method, reach members through self. A bare name always means a global, never a field.

~ 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.

=== struct Scene ===
VAR title = "..."
= intro(who)
{self.title}: Hello, {who}!
->->
===
-> 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 ~.

Operators#

Operators in Writing with Sumi

= 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.

=== struct Vec2 ===
VAR x = 0
VAR y = 0
= operator +(other: Vec2) =
~ temp r: Vec2
~ r.x = self.x + other.x
~ r.y = self.y + other.y
~ return r
===

+= 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}.

Inheritance#

Inheritance in Writing with Sumi

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.

=== struct Oswald: Character ===
VAR name = "Oswald"
= function Introduce() =
~ base.Introduce()
~ return
===
VAR someone: Character = Oswald
~ 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.

{Oswald is Character}
{(someone as Oswald).name}
{someone is Oswald o: {o.name}}
WARNING

Reading a field or calling a method on none is a runtime error, so test with is first.

References#

References in Writing with Sumi

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.

REFVAR here: Character = hero
~ here.name = "Oswald" // hero.name changes too
~ 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.

=== function Rename(ref who: Character, newName) ===
~ who.name = newName
~ return

Dynamics (Sumi)#

Dynamics (Sumi) in Writing with Sumi

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.

=== dynamic Bag ===
VAR score = 0
===
VAR bag: Bag
~ bag.extra = 5 // creates the slot
{bag has extra}
~ 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.

~ bag.item = hero // copy
~ bag.item = ref hero // 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.

Loops (Sumi)#

Loops (Sumi) in Writing with Sumi

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.

{ for i in range(0, 3): {i} }
{ while n > 0:
~ n--
}

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.

{ for key, value in fields(bag):
{key} = {value}
}
{ for item in inventory: {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.

Loop limit#

Loop limit in Writing with Sumi

A loop stops after 32 rounds so a mistake cannot freeze the editor. Raise it with #PRAGMA loop_limit (see Preprocessor). 0 means unlimited.

#PRAGMA loop_limit 256

Preprocessor#

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.

Conditional text#

#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.

#IF INKY
~ debug = true
#ELIF PREVIEW && !SHIPPING
~ debug = true
#ELSE
~ debug = false
#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.

Pragmas#

#PRAGMA name argument configures the compiler. The line is removed from the story. It is also subject to #IF, and an unknown name is an error.

PragmaEffect
loop_limit NMost rounds a for / while may run. Default 32, 0 is unlimited. Last one wins.
implicit_tostring MethodStruct method used to print a value, so {hero} and hero :: Hello. call hero.Method(). Last one wins.
external_source pathC# file or folder to compile and bind. See C# externals. May appear many times.
command_identifier prefixPrefix that marks a story line as a player command instead of text. Last one wins.
#PRAGMA implicit_tostring DisplayName
=== struct Character ===
VAR name = "Anon"
= function DisplayName() =
~ return self.name
===
VAR hero: Character
~ hero.name = "Flinn"
{hero}
hero :: Hello.
TIP

Without the pragma, :: is just plain text. Only implicit_tostring turns hero :: Hello. into a speaker line.

C# externals#

C# externals in Writing with Sumi

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.

The basics#

The basics in Writing with Sumi

Declare the function in ink, and give a fallback with the same name and parameters for tools that cannot bind it.

EXTERNAL playSound(name)
=== function playSound(name) ===
~ return // fallback, used if nothing is bound
~ playSound("whack")

Parameters and results can be int, float, bool or string. An EXTERNAL line is also allowed inside a struct.

Pointing at C# files#

#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.

#PRAGMA external_source "game/Hooks.cs"
#PRAGMA external_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.

What your class may define#

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.

using Ink.Runtime;
public class Hooks
{
int gold;
// Called once before play: register your externals.
public void Bind(Story story)
{
story.BindExternalFunction("playSound", (string name) => { /* ... */ });
story.BindExternalFunction("addGold", (int n) => { gold += n; });
}
// Game-state panel: read and write your own state.
public string Serialize(Story story) => gold.ToString();
public void Deserialize(Story story, string state) => gold = int.Parse(state);
// Commands the player prompt offers.
public void GetCommandList(InkyCommandList list)
{
list.Add(new InkyCommand("give", new[] { "amount" }, "Add gold",
args => gold += int.Parse(args[0])));
}
}
WARNING

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.

Things to know#

  • 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.

Names#

Names in Writing with Sumi

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.

Sumi Cheat Sheet
https://fukafukaseika.moe/posts/sumi-cheat-sheet/
Author
š“‡Œ Runna š“‡Œ
Published at
2026-10-16
License
CC BY-NC-SA 4.0