In this article I'll walk you through using the Compromise NLP JavaScript library to interpret player input in text-based adventure games.
Interactive Fiction
In text-based games, also known as Interactive Fiction or IF, the game describes a room to the player who then types in a command and the game responds.
If you're familiar with Zork, Enchanter, Anchorhead, or even Colossal Cave Adventure, you already know what I'm talking about. If not, below is a snippet of a portion of a made-up game:
Home Office
Your home office was never clean by any stretch of the imagination, but it's been worse since the accident. You tell yourself that there's a method to the madness and you know where to find anything of importance, but even you have to admit that when you need to keep the dog out of a room for fear of him ripping up bank paperwork that you have issues.
Your desktop computer is here on the desk. It looks to be busy installing important system updates at the moment.
A set of glass double doors leads east to your hallway.
Look at the computer screen
The computer displays an animated progress indicator with no signs of getting anywhere close to complete.
Unplug the computer
Not right now! It's busy installing important updates!
Hopefully you get the idea. The game describes a situation and the player types a command to interact with the environment in a way the designer hopefully expected and has a meaningful response available for.
AngularIF
Whenever I learn a new language, almost invariably I'll write a text-based game in that language. It's how I learned architecture, design, and nuances of various languages as a kid and there's a certain nostalgia to it.
And so, a few years ago, I realized I never did this with JavaScript or TypeScript and set out to build a text-based game engine in Angular. While the project, AngularIF, lost my interest over time, the core engine and how it parsed things was very interesting.
Angular IF uses a custom variant of Angular Material and Materialize CSS, but at its core, it's the same concept as the old black and white text adventures.
The user types a sentence into the UserInputComponent
which is then sent to the InputService
which interprets the input and updates the story, emitting an event that the StoryViewComponent
receives and displays to the user.
So how does the game make sense of what the player types?
Parsing Text Input
At a high level, Angular IF does the following things with user input:
- Tokenize the input text into individual word 'tokens' that can be interpreted
- Use a lexer to get contextual information on parts of speech for the individual tokens
- Parse the tokens into a web of dependencies, making grammatical sense of the sentence
- Interpret the objects in the sentence with objects in the local environment where possible
- Execute the interpreted command by sending it to the appropriate verb handler
I'll break these out in more detail with a sample sentence in the following sections.
A Sample Scenario
Take a look at the following room description with added emphasis on declared objects:
Cloakroom
The walls of this small room were clearly once lined with hooks, though now only one remains. The exit is a door to the east.
We're going to parse the following short sentence:
Put my cloak on the small hook
Before we look at how this breaks down, it should be noted that AngularIF requires sentences structured like this. Specifically it requires an imperative sentence starting with a verb. This greatly restricts the types of things users can type and makes the problem much more manageable.
Tokenizing and Lexing
We're going to talk about Tokenizing and Lexing in tandem because both of these tasks are accomplished in AngularIF via a call to the Compromise NLP library.
Compromise is a simple JavaScript library designed to tokenize, lex, and even transform text. It's designed to be fast, simple, and handle the vast majority of what people need from a text parsing library, by compromising on the harder things that fewer people want to do.
In AngularIF, tokenizing looks like this:
private extractTokensFromInput(sentence: string): CommandToken[] {
const lexer = LexiconService.instance;
sentence = lexer.replaceWords(sentence);
// Break down the input into command tokens
const tokens: CommandToken[] = this.nlp.getTokensForSentence(sentence);
// Some tokens are shortcuts for common actions. These should be replaced as if the user had spoken the full word.
lexer.replaceTokens(tokens, this.nlp);
return tokens;
}
Before we call compromise, we do some standard string replacement to clear up any ambiguous or compound words that Compromise has demonstrated potential to get confused by:
"substitute": {
"pick up": "get",
"climb up": "climb",
"turn on": "activate",
"turn off": "deactivate",
"north east": "northeast",
"north west": "northwest",
"south east": "southeast",
"south west": "southwest",
"cannot": "can not",
"weed whacker": "whacker",
"front yard": "frontyard",
"side yard": "sideyard",
"back yard": "backyard",
"inside": "in",
"outside": "out"
},
After this replacement is complete, we call getTokensForSentence
on a NaturalLanguageProcessor
class I wrote that wraps around the compromise library. This method calls to a few other methods that ultimately chains down to this call:
public getTerms(sentence: string): LanguageTerm[] {
// Commas are death.
sentence = StringHelper.replaceAll(sentence, ',', '');
const lexicon = LexiconService.instance.lexicon;
const data: LanguageTerm[] = this.nlp(sentence, lexicon).terms().data();
return data;
}
Here nlp
is the instance of compromise.
So, back to the example, the phrase put my cloak on the small hook
would parse out the token put
as the following:
{
"spaceBefore":"",
"text":"put",
"spaceAfter":"",
"normal":"put",
"implicit":"",
"bestTag":"Verb",
"tags":[
"PastTense",
"Verb",
"VerbPhrase"
]
}
So here we see that Compromise thinks that put
is a verb that can be used in the past-tense or as part of a verb phrase, but Compromise's best guess is that put
is a verb. It's correct.
And so we see that with a simple call to Compromise, we get a lot of information on parts of speech that didn't require any custom definitions at all.
If I give Compromise a word it has no idea about, it tells me what it does know about it. For example, the input Madeupword
gets interpreted as follows:
{
"spaceBefore":"",
"text":"Madeupword",
"spaceAfter":"",
"normal":"madeupword",
"implicit":"",
"bestTag":"Noun",
"tags":[
"TitleCase",
"Noun",
"Singular"
]
}
So here, it interprets it into a Noun as its best guess and tells me that it appears singular based on the end of the word and it's in title case. Defaulting to a noun is a very good decision in our case, because new nouns are much more likely than new verbs with a fairly limited set of actions supported by most text-based games.
Parsing
Now that we have a set of parsed terms, we can start to make sense of the ordering. Right now we have the following:
- put (Verb)
- my (Adjective)
- cloak (Noun)
- on (Preposition)
- the (Determiner)
- small (Adjective)
- hook (Noun)
AngularIF looks at that and immediately notes that it doesn't start with a Subject, so the game implicitly adds I (Noun) to the beginning of the sentence. With a bit of styling from AngularIF's debugging view, our sentence can now be displayed in the following way:
Here the color coding and relative sizing of the elements helps us start to make sense of the sentence. We really care about a verb and a sequence of objects that can be fed into the verb handler. The verb and objects are easy, but let's look at the other words.
The adjective my applies to the noun cloak, so it becomes attached to that.
The preposition on and the determiner the both similarly apply to the noun hook.
Given these modifiers, we can represent our sentence as I put cloak hook
. The on preposition is actually important as many verb handlers need to know if you're trying to do something under, above, inside of, on, etc. but for the simplicity of sentence parsing, our main functions are the nouns and verb.
The sentence can then be represented as follows:
Now it's becoming a lot more clear what the user is actually saying, due to the structure of imperative sentences and the information Compromise is providing.
The next step is to interpret the meaning of these nouns.
Interpreting
Here we take a look at all of the nouns listed in the command and we try to map them to objects registered in the current room or attached to the player. We also need to match against some constant things such as cardinal directions.
This is fairly easy to do based on matching registered adjectives and synonyms for objects present in the room, so I'll spare that code and focus more on the parser.
After interpreting our input it looks like the following:
Here we are able to make sense of what the user is talking about in the environment and have something concrete to hand off to the engine to execute.
If the user tried to refer to something that wasn't coded as existing in the room, the interpreter could be unable to resolve some of the nouns and you'd get something like the following:
Here, the word bug was recognized as a noun, but was not mapped to any known game concept, so the game engine would respond back:
You don't see a bug here.
If only all responses were as bug-free.
Execution
Now that a completed graph of the user's intent is available, the system looks for a handler registered for the verb the user entered. For example, with the put verb, the system knows about it and invokes it, passing in the sentence graph. The handler looks at the objects in the sentence and it knows that the first object will be what we're putting and the second will be where we're putting it (and how, if there's a preposition such as under).
If a verb handler doesn't have all the info it needs or is confused, it can spit back a custom tailored response to the user.
If the user tries a verb that doesn't have a handler, the system can say back something like:
You won't need to use the verb
eat
to win the game. For a complete list of verbs available, typeWhat can I do?
Fortunately, putting the cloak on the hook is perfectly valid and the system spits back:
Your score has just gone up by 1 point.
You hang the black velvet cloak on the small brass hook.
Next Steps
While this is a high-level overview of sentence parsing using Compromise NLP, I'm hopeful that this article gets you thinking about the things the library can help you achieve. I strongly recommend you look over the compromise website for a wide variety of examples and next steps.
If you're curious about my own code for AngularIF, the code is available on GitHub. I should warn you that it is in Angular 4 still and has a significant number of vulnerabilities and bugs, so I recommend you update dependencies if possible. Still, the code should be instructive to anyone interested in learning more about parsing imperative sentences.
If you do something cool with either compromise or AngularIF, please let me know; I'd love to hear about it.
Top comments (0)