tl;dr Check out my NPC-talk-script-approach ported to JavaScript and pushed to github. A simple text file syntax can be enough to control NPC dialogs.

About

SchnackScript (pronounced snack script) is a simple-to-write, simple-to-parse and simple-to-execute “scripting language”. It is meant to write and process dialog-text-files for NPC’s in game like environments. In those scripts you can output text, set and change variables on a shared state and use if-conditions to branch the output.

I ported SchnackScript manually from C++ to JavaScript and pushed it as part of my JavaScript open source engine: TouchThingJS. I used the script engine for a wide range of NPCs and it covered many use cases I had while creating a game.

The port is not optimized when it comes to garbage collection but it is pretty stable and should be simple to integrate. Don’t expect cutting edge parser technology, though.

Why call it SchnackScript? Well… in north Germany to schnacken is a term for chatting. Also, my name.

SchnackScript language

A SchnackScript is a simple text file and can be parsed using the SchnackInterpreter (which internally uses the SchnackParser of course). Everything that is not an instruction is treated as TEXT. This is similar to PHP where everything is text/HTML unless it is between <?php ... ?>.

The script supports the following control structures:

@if
@else
@elseif
@set
@end
@message
@select

There are no functions, no classes, but we have variables and scopes.

Here a very simple example

# blacksmith.txt
@set count ++
@if count == 1
  Hello,
  I am the blacksmith
@elseif count == 2
  Sorry, no time to talk.
@else
  Boy, go and bother someone else.
@endif

Variables

A variable can be created or altered with @set. A variable can be of type string or number.

# create new variable called: name as string
@set name = foo

# create new variable called: hearts as number
@set hearts = 1

# increment a variable by 1
@set hearts ++

# append "bar" to the name
@set name .= bar

@set supports the following operators:

# increment
@set myVar ++

# increment by 5
@set myVar += 5

# decrement
@set myVar --

# decrement by 5
@set myVar -= 5

# concat a string
@set myVar .= abc

The type is set automatically by the operator:

@set name = foo
#> name = "foo" << type is string
@set name ++
#> name = 1 << type changed from string to number

@set myStr = 100c
#> myStr = "100c" << type is string

@set myNum = 100
#> myNum = 100 << type is number
@set myNum .= c
#> myNum = "100c" << type is string now

Important: it is not possible to read values of other variables.

@set num = 10
#> num = 10 << type is number
@set hearts = num 
#> hearts = "num" << type is string!!!!!

Text output and conditions

As said, the idea is to write scripts to let NPCs talk. The parser supports that by simply outputting text. If we just write text into our schnack-script-file we just get that text.

# just text
Hello,
I am the blacksmith

The parser would turn this into this:

"Hello,\nI'm the blacksmith\n"

By using @set and @if we can bring some life into the NPC.

# text with counting
@set count ++
Hello,
I am the blacksmith
@if count >= 3
  Please go and bother someone else.
@endif

In this example the parser output would differ depending on the amount of times we execute the script.

"Hello,\nI'm the blacksmith\n" // first time
"Hello,\nI'm the blacksmith\n" // second time
"Hello,\nI'm the blacksmith\nPlease go and bother someone else.\n" // third time

With @else and @elseif we can also do things like this:

@set count ++
@if count == 1
  Hello,
  I am the blacksmith
@elseif count == 2
  Sorry, no time to talk.
@else
  Boy, go and bother someone else.
@endif
"Hello\nI'm the blacksmith\n" // first time
"Sorry, no time to talk.\n" // second time
"Boy, go and bother someone else.\n" // rest of the times

Variable namespace

To handle multiple NPCs SchnackScript provides an automatic variable namespace. The idea is that each NPC is one schnack-script-file.

Think of a village with a blacksmith.txt, a barkeep.txt and a king.txt as NPC script files. The name of the file defines the namespace for each NPC in the global state storage. When we use @set in one of those scripts it is always in the namespace of the given script.

# blacksmith.txt
@set count ++

# barkeep.txt
@set count ++

# king.txt
@set count ++

If we execute each script once it would change the global state like this:

console.log(globalState);
=> {
  "blacksmith.count": 1,
  "barkeep.count": 1,
  "king.count": 1,
}

SchnackScript implicitly takes the script-name as a prefix for the variables. That way we can reference the data of other scripts as well.

# blacksmith.txt
@set count ++
@if king.count >= 1 # <<<<<<<<<<<<<< access data from king.txt
  Hello,
  a friend of the king is always welcome.
@else
  @if count == 1
    Hello,
    I am the blacksmith
  @elseif count == 2
    Sorry, no time to talk.
  @else
    Boy, go and bother someone else.
  @endif
@endif

Here, the blacksmith checks if we talked to the king or not. If we did he acts way more friendly.

"Hello\nI'm the blacksmith\n" // first time
"Sorry, no time to talk.\n" // second time
"Boy, go and bother someone else.\n" // third time

// after talking to the king:
"Hello\na friend of the king is always welcome.\n"

There is no need to register namespaces. We can just use them as we go. For example we could just store the amount of our hero’s items in the items namespace just like this:

# king.txt
@set count ++
Hello,
I am the king!
@if count == 1
  Here, take this gift!
  @set items.bow = 1       # << sets the bow variable
  @set items.arrows += 10  # << adds or sets the arrows to 10
@endif

This script sets the variable bow in the items namespace. The game code can then make the bow available to the player. As a result the hero gets the item bow when he talks to the king for the first time. It is not necessary to have a items.txt. We can just use as many namespaces as we need.

One nice detail: the script adds ten arrows to the hero. Depending on the state the += operator would either create the arrows variable and set to ten or increment the existing value by ten.

Variables lifetime and scopes

Processing the variables the SchnackScript uses two special namespaces session and map. They help to manage the lifetime of variables and prevent flooding the global-state with helper variables.

In a script they can be used like any other namespace. However, the SchnackInterpreter keeps track of the scope of each variable. All variables so far were automatically in the global scope. But when we set variables in map or session namespace they are put in a different scope.

# blacksmith.txt
@set hearts ++               # GLOBAL scope
@set king.count = 0          # GLOBAL scope
@set map.destroyedDoor = yes # MAP scope
@set session.counter ++      # SESSION scope

In the game code it is possible to discard their data by simply calling:

interpreter.releaseSessionScope();
interpreter.releaseMapScope();

The idea of session-scope is to be able to set variables that only exist as long as we are in an active talk-session. When our hero begins a conversation with the blacksmith the session-scope is empty. The script can create, change and set variables during that conversation. Once the conversation ends all variables of the session-scope are removed.

Map-scope works the same. However, the idea is here to release the variables when the hero moves from one map to another (or from one level to the next). This very much depends on the needs of the given game.

In game code it is also possible to overwrite the scope of variables.

Get user input using @select

Using @select it is possible to ask the user for input out of a pre-selected list of options.

# barkeep.txt
Welcome
@select
  question_drink: Do you like a drink?
  milk: A Glas of milk
  wine: Bottle of wine
  aimfruit(items.bow = 1): A shot of Aim-fruit-drink
  none: Nothing
@endselect

In the example the user is ask to select a drink. It is possible to filter answers if a condition isn’t true (see aimfruit). The system must enable the user to select one of the given values. Then the script is parsed from the beginning again only this time the variable question_drink contains the selected answer.

So, let’s handle that too.

# barkeep.txt
@if question_drink == milk
  Sorry, no milk.
@elseif question_drink == wine
  Wine? Not for you.
@elseif question_drink == aimfruit
  Very well!
@elseif question_drink == none
  Too bad. Come back if you like something else. 
@else
  Welcome
  @select
    question_drink: Do you like a drink?
    milk: A Glas of milk
    wine: Bottle of wine
    aimfruit(items.bow = 1): A shot of Aim-fruit-drink
    none: Nothing
  @endselect
@endif

And this is how one can create question/answer dialogs. We can also have more than one @select in a script to make more complex conversations. If the content of the selection need to be more dynamic one can always create the script-text during runtime and send that to the parser.

To make sure that the decision variable is not set forever it is highly recommended that the game code sets the scope of the answer-variable (here question_drink) to SESSION. That way the barkeep will always start with the welcome part. If it is required to remember a decision one can simply set a variable instead.

Change music, show rewards and other effects with @message

When having a conversation with an NPC we sometimes want to be able to adjust the the music, play a sound or do other things. For that one can send arbitrary events with @message.

Let’s adjust the king-script a bit:

# king.txt
@set count ++
Hello,
I am the king!
@if count == 1
  Here, take this gift!
  @set items.bow = 1      
  @set items.arrows += 10 
  @message changeMusic, orchestral.ogg # << send to the game code
@endif

@message is a very generic API and allows the script to notify all sorts of things to the running game. It is up to the game code to handle messages. In this example the music system of the game needs to listen to script events and if changeMusic is sent it needs to change the music.

Change speaker in a text

The given examples all assume that there is always only one speaker. But, inherently SchnackScript is built to deal with multiple speaker in one text. To make use of that one can set the name/id of a speaker.

Here an example:

# blacksmith.txt
Some Text

[Hero]
Hello, 
can you help me?

[Blacksmith]
No!

The parser will return this:

[
  { personId: "", 
    text: "Some Text\n"  },
  { personId: "Hero", 
    text: "Hello,\ncan you help me?\n"  },
  { personId: "Blacksmith", 
    text: "No!\n" }
]

In the game code one can now decide how it makes sense of the speaker information. It is also okay to ignore it and extract the talk source from the game environment itself.

Formated text, animate words or use emojis?

Not strictly a part of SchnackScript but a useful thing to have is the SchnackFormatParser.

Schnack, schnack, schnacksmurf naming convention, I know.

In nearly all games that might apply for SchnackScript we like to have little parts within our text. Much like the things we can do in HTML. Being designed for C++ in a very narrow environment (Android 2.2 NDK) I came up with my own approach.

In absence of a proper EBNF I give some examples.

# king.txt
[king]
Hello {hero:}!
I expected you. I see you found my {quest:treasure}.

This contains two kinds of symbols. The first one is hero and is empty. The second is quest and has the text treasure in it.

The SchnackInterpreter will translate this to:

[{
    personId: "king",
    text: "Welcome {hero:}!\nI expected you. I see you found my {quest:treasure}.",
}]

With SchnackFormatParser we can walk over the tokens of that text. The result would be:

const cb = (typ, symbol, stack, raw, rawIndex) => {
    console.log(typ, symbol, stack, raw, rawIndex);
};
const raw = SchnackFormatParser.parse("Hello {hero:}!\nI expected you. I see you found my {quest:treasure}.", cb);

//> start hero [ 'hero' ] Welcome  8
//> end   hero [] Welcome  8
//> start quest [ 'quest' ] Welcome !\nI expected you. I see you found my  46
//> end   quest [] Welcome !\nI expected you. I see you found my treasure 54

Using the parser we have a callback function that is called every time a new symbol is introduced in the text and when one is gone. When writing a text-renderer for the game this can be used to change the color, content or the animation of a text.

The parser is also able to dry out the symbols and just return the content text (the raw values in the example).

It is also possible to build nested symbols:

# blacksmith.txt
What? You again?
{scream:I told you, you are not welcome here!
You destroyed my {item:lamp} my precious lamp {sob:}}
//> start scream [ 'scream' ] What? You again?\n 18
//> start item [ 'scream', 'item' ] What? You again?\nI told you, you are not welcome here!\nYou destroyed my  74
//> end item [ 'scream' ] What? You again?\nI told you, you are not welcome here!\nYou destroyed my lamp 78
//> start sob [ 'scream', 'sob' ] What? You again?\nI told you, you are not welcome here!\nYou destroyed my lamp my precious lamp  96
//> end sob [ 'scream' ] What? You again?\nI told you, you are not welcome here!\nYou destroyed my lamp my precious lamp  96
//> end scream [] What? You again?\nI told you, you are not welcome here!\nYou destroyed my lamp my precious lamp  96

The source code

The code can be found on github. To harden the code I also pushed the test cases into the repo.

Right now I need it for a game I’m working on so the simplest thing for me was to put it into my game engine repo. In a later stage I might take the effort and publish it as a npm package.