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, schnack … smurf 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.