Skip to main content

Syntax

Dialogue

Boughs

Sections of dialogue are called Boughs, and are denoted with === [Name] {}. So === START {} is a bough called START with an empty "body".

Lines

Lines have three sections. :?[Speaker]: "[Content]" ?[#Tag]

Speakers are optional and can be empty with ::. Tags are also optional and can be omitted or multiple can be separated each with their own hash (#).

:: "The scent of crushed lavender hangs heavy in the air."
:Detective: "A curious place for a murder, wouldn't you say?" #stern #suspicious

Jumps

Boughs can be started with jumps, denoted with => [Name]. The first jump must be passed in to the start function, or if using the CLI, the bough flag of the run command.

# topi run file.topi --bough GATEHOUSE

=== GATEHOUSE {
:Gardener: "I was just pruning the hydrangeas, I swear!" #nervous
:Detective: "With a blood-stained trowel, Mr. Higgins?"
=> FLEE
}

=== FLEE {
:: "The gardener dashes away, making his escape through the shrubbery"
}

Nesting

Boughs can be nested and jumped to with . like so:

# topi run file.topi --bough ESTATE.MAZE

=== ESTATE {
=== MAZE {
:Detective: "Lost in the hedges... and so was the victim."
}
}

Forks

To create interesting and interactive stories we need to be able to divert the story depending on choices answered by the player. For that we use the keyword fork with each choice prong denoted with a tilde ~ ?[NAME] "[CONTENT]" ?[#TAG].

=== FOUNTAIN {
:Detective: "The water is clouded. Should I reach in?"
fork {
~ "Search the basin" {
:: "Your fingers brush against a cold, silver key."
inventory.add("Silver Key")
}
~ "Examine the statue" #leave => STATUE_CLUE
}
}

Forks can be named for looping interactions.

=== INTERROGATION {
fork TOPICS {
~ "Ask about the Alibi" {
:Lady_Margery: "I was in the conservatory all evening!"
=> TOPICS
}
~ "Ask about the Poison" {
:Lady_Margery: "I use that for the slugs, nothing more."
=> TOPICS
}
~ "Leave her be" #leave => GARDEN_PATH
}
}

Choices can be unique with ~*, disappearing once selected.

=== EVIDENCE_BAG {
fork GATHER {
~* "Take the Torn Letter" {
:: "It's addressed to the Butler..."
=> GATHER
}
~ "Finish searching" => GAZEBO
}
}

Visits

Use paths to check how many times a player has visited a location or made a choice.

=== ROSE_GARDEN {
if ROSE_GARDEN > 5 {
:Detective: "I've paced this path so many times."
}
:: "The roses are suspiciously red."
=== YELLOW_ROSE {
:Detective: "But the yellow ones are the most fragrant."
}
=> YELLOW_ROSE
}

=== GARDEN_PATH {
:Detective: "What am I missing?"
if ROSE_GARDEN.YELLOW_ROSE > 1 {
:Detective: "I've already checked the yellow roses."
}
...
}

Flow, Fin, and Jump Back Ups

Sometimes we want to temporarily duck into a bough and then continue on. We can do that by adding a caret ^ to a jump =>^ [NAME] (meaning jump then come back up).

=== CRIME_SCENE { 
:Detective: "Let's see what the coroner has to say."
=>^ EXAMINE_BODY
:Detective: "Just as I thought. Pruned to death."
}

=== EXAMINE_BODY {
:: "The victim is face down in the petunias."
:Coroner: "Cause of death; stabbing by a pair of gardening shears."
}
}

Backups can also be applied to forks with fork^.

=== GARDEN_PATH { 
:Detective: "Before we go on, let's review what we think we know."
fork^ {
~ "Inspect the muddy footprints" {
:: "The prints are narrow—fashionable boots, not gardening clogs."
}
~ "Re-read the torn letter" {
:: "Only three words remain legible: 'meet me—midnight—'"
}
~ "Pocket the silver key and move on" {
:: "You tuck it away. It feels heavier than it should."
}
}
:Detective: "Right. Back to our suspect—before the roses start listening."
}

A story will end when it hits a fin regardless of where it is or if it's within a jump back up.

=== GARAGE {
:Driver: "I will not let you ruin everything I've worked so hard for, Detective."
:Detective: "What do you mean?"
:: "The Driver hits you in the back of the head with a wrench."
=>^ ATTACKED
:: "The Driver runs off as you wearily pick yourself up."
}

=== ATTACKED {
health -= 10
if health < 0 fin // Will end the story here
}

Code

Variables

Variables can either be mutable var or constant const. Constant variables cannot be reassigned.

var cluesFound = 0
const victimName = "Lord Ashbury"

victimName = "Lord Ashbury the Great" // Error:

Types

Topi supports the following types

  • bool: true/false
  • string: "text"
  • number: 1,1.1,-2.2

Collections

Topi has three builtin collection types. Lists, Sets, and Maps. Each type has five builtin methods attached as well. add, remove, has, clear, count

Lists are a collections of values in order.

var suspectList = List{"The Butler", "The Maid", "The Cook"}
var emptyList = List{}

suspectList.add("The Driver")
suspectList.remove("The Cook")
suspectList.count() // 3
suspectList.has("The Mechanic") // false
suspectList.clear()

Sets are a collection of unique values.

var clues = Set{"Bloody Shears", "Torn Letter", "Hidden Handkerchief"} // Unique items only
const emptySet = Set{}

clues.add("Torn Letter") // no change
clues.count() // 3
clues.remove("Hidden Handkerchief")
clues.has("Hidden Handkerchief") // false
clues.clear()

Maps are a collection of key/value pairs.

const evidenceWeights = Map{"Shears": 10, "Letter": 5}
const emptyMap = Map{}

evidenceWeights.add("Handkerchief", 5) // (add will replace values if already exists)
evidenceWeights.count() // 3
evidenceWeights.remove("Letter")
evidenceWeights.has("Shears") // true
evidenceWeights.clear()

String Methods

Strings have several builtin methods for common operations.

MethodDescription
length()Returns the length of the string
has(substring)Checks if the string contains a substring
upper()Returns the string in uppercase
lower()Returns the string in lowercase
replace(from, to)Replaces all occurrences of a substring
split(delimiter)Splits the string into a list
substr(start, end)Returns a substring from start to end (inclusive)
trim()Removes leading and trailing whitespace
var clue = "  The Butler Did It  "

clue.trim() // "The Butler Did It"
clue.trim().length() // 17
clue.trim().upper() // "THE BUTLER DID IT"
clue.trim().lower() // "the butler did it"
clue.trim().has("Butler") // true
clue.trim().replace("Butler", "Maid") // "The Maid Did It"
clue.trim().split(" ") // List{"The", "Butler", "Did", "It"}
clue.trim().substr(4, 9) // "Butler"

Loops

While

while [CONDITION] { } While loops will execute so long as the condition is met.

var intensity = 3
while intensity > 0 {
:: "The thunder rolls..."
intensity -= 1
}

For

for [ITERATOR] | [ITEM] | { } For loops can be a range (inclusive), or a collection.


print("Clues:")
for 0..2 |i| {
print("{clues[i]}")
}

for suspectList |person| {
print("Interrogating {person}...")
}
for evidenceWeights |evidence| {
print(evidence.key) // "Shears", "Letter", "Handkerchief"
print(evidence.value) // 10, 5, 5
}

Control flow

Code and dialogues can be conditional with if/else. Braces are optional if only one line is used, though readability is improved for some cases.

var suspicion = 0 
if gardener_has_trowel { suspicion = 10 }
else if found_blood_stain { suspicion = 50 }
else suspicion = 0

if suspicion > 40 :: "The gardener begins to sweat profusely."

Switch statements can also be used.

Breaks are implied on the first case which matches. Multiple conditions can be matched with commas (,), and if using numbers, Ranges (n..m) can also be used.

else statements are optional, if no prongs match, it will be skipped.

switch found_item { 
"Trowel": :Detective: "This trowel matches the wound perfectly!",
"Letter": :Detective: "A confession, hidden in the roses?",
else: :Detective: "Nothing but dirt and petals here."
}

switch suspicion {
0..10: print("Hardly a suspect."),
11..50: print("Keep an eye on them."),
51..90: print("Focus our efforts on them.")
91..100: print("Arrest them at once!")
}

switch lady_margery_location {
"Kitchen", "Cellar", "Stable": :: "She has no alibi for the time of the murder.",
"Conservatory", "Parlor": :: "The staff confirm she was taking tea.",
else: :: "Her whereabouts remain a mystery."
}

Strings and Inline Code

Expressions can be evaluated within a dialogue or choice line with braces {} as an interpolated string.

var weapon = "Poisoned Tea"
var location = "Library"
=== CONFRONTATION {
:Detective: "It was you, with the {weapon + " in the " + location}!"
}

Double quotation marks can be added by 'escaping' them with backslash / -- "/"Hello/", he said." will output "Hello", he said

Functions

fn [NAME] | ?[PARAMETER,] | { }

Braces are optional if only one line is used.

Functions cannot contain Boughs only logic.

fn sum |x, y| return x + y

fn fib |n| {
if n < 2 return n
return fib(n - 1) + fib(n - 2)
}

Return Void

If you want to return out of a function early, you have to specify return void and not just return like most langauges.

fn early || {
if true return void
}

Enums

enum [NAME] { [VALUE,] }

Used for static state values, while enumseq for state values that only move forward.

Sequences are useful in that all previous states are inferred from the current. If the player is investigating, they must have arrived on the scene. Same if they are accusing someone, they must have investigated, and so on. For more information you can watch this great talk by Inkle's Jon Ingold.

enum Location { Library, Garden, Attic }

enumseq CaseStatus {
Arrived,
Investigation,
Accusation,
Solved
}

var location = Location.Library
location = Location.Garden
location = Location.Attic

var current_state = CaseStatus.Discovery
current_state = CaseStatus.Investigation
current_state = CaseStatus.Discovery // This assignment is ignored!

Under the hood both they are just index integers, which cannot be changed. This does mean you can use comparative operators with enums.

print(Location.Library < Location.Attic) // true
print(Location.Library == 0) // true

Classes

class [NAME] { [FIELD,] [METHOD] } Classes are an encapsulation of named data. All fields must be given a default value. Instances of classes are created with the new keyword. Any field not initialized will use the default value.

Classes can also have function as methods, references to its own fields can be achieved with self

class Suspect {
name = "Unknown",
suspicion_level = 0,

fn implicate |amount| {
self.suspicion_level += amount
}
}

var butler = new Suspect { name = "Barnaby" }
butler.implicate(50)
print("I am {butler.suspicion_level}% sure {butler.name} is our culprit.")

Fields can also be indexed as well with [] notation. This can be useful for function calls from game applications. As an example with the above class:

fn changeField |instance, fieldName, newValue| {
instance[fieldName] = newValue
}

changeField(butler, "name", "Barnaby Farnsworth")
changeField(butler, "suspicion_level", 0)
print("Knowing {butler.name} is a Farnsworth excludes him from the investigation.")

Multiple Files

Multiple files can be joined together to create a single story using include "[PATH]". All files are added into the global scope, meaning you can't have the same variable names in multiple files. This is due to variables engine integration, which would not know how to distinguish between 'file1.variable' and 'file2.variable'.

Currently, circular dependencies are not allowed.

// main.topi
include "./utils.topi"
sum(1, 5)

// utils.topi
fn sum |x, y| return x + y

Note circular includes are not allowed.

Not allowed

// main.topi
include "./garden.topi"

// other.topi
include "./dining_room.topi"

Code Execution and Jumps

When jumping to nested boughs, Topiary ensures all parent variables are initialized first.

Consider the following

# topi run file.topi TEA_PARTY.SERVE

=== TEA_PARTY {
var poison_present = true
=== SERVE {
if poison_present :Detective: "I wouldn't drink that if I were you."
}
}

In this situation we need to create the poison_present variable before it can be used in the if statement. To ensure that happens when you jump to TEA_PARTY.SERVE first Topiary will jump to TEA_PARTY execute all code (while skipping dialogues and forks), then when it encounters another jump or ends, it'll then jump to SERVE.

For this reason it's recommended that all code be placed at the top of all files and boughs.

Built-in Functions

General

FunctionDescription
print(value)Prints a value to output
rnd(start, end)Returns a random integer in the range [start, end]
rnd01()Returns a random float between 0.0 and 1.0
round(number)Rounds a float to the nearest integer
abs(number)Returns the absolute value of a number
assert(expr, msg)Asserts expression is true, returns message if false
mstime()Returns the current millisecond timestamp
var damage = rnd(1, 20)
var chance = rnd01()
var distance = abs(-5) // 5
var rounded = round(3.7) // 4
print("Rolled {damage} damage")
assert(damage > 0, "Damage must be positive")

Text Variation

These functions select items from a list and track state across calls, making them useful for varying repeated dialogue.

FunctionDescription
cycle(list)Returns items in order, looping back to the start
sequence(list)Returns items in order, repeating the last item once exhausted
shuffle(list)Returns items in a random order, reshuffling when exhausted
random(list)Returns a random item each time
weighted(items, weights)Returns a random item based on a list of weights
var greetings = List{"Hello", "Hi", "Hey"}

// cycle: Hello, Hi, Hey, Hello, Hi, Hey, ...
cycle(greetings)

// sequence: Hello, Hi, Hey, Hey, Hey, ...
sequence(greetings)

// shuffle: Hey, Hello, Hi, Hi, Hey, Hello, ... (random order, reshuffles each pass)
shuffle(greetings)

// random: Hi, Hi, Hello, Hey, ... (fully random each call)
random(greetings)

weighted takes two lists — items and their corresponding weights. Higher weights increase the chance of selection. Both lists must have the same length or a runtime error will be raised.

var outcomes = List{"Miss", "Hit", "Critical"}
var weights = List{50, 40, 10}

weighted(outcomes, weights) // "Miss" 50% of the time, "Hit" 40%, "Critical" 10%