TBA is a C++ library for making text-based adventure games. It is written using modern C++ and exported as a module. Aspects of generic programming and functional programming style are incorporated, and the library makes heavy use of templates and concepts.
This is a final project created for the COMS 4995 Design Using C++ course with Prof. Bjarne Stroustrup in Spring 2021.
Project members:
Rounak Bera
Justin Chen
Manav Goel
To illustrate library usage, we have created a tutorial game. We will construct this game here. A working expanded version of it can be found in tutorial_game/
.
The GameRunner
forms the central component of the game. We can start a basic game as follows:
GameRunner<DefaultGameTalker, DefaultGameState> gameRunner {};
gameRunner.runGame();
Before we runGame()
, however, we would probably like to define some game information.
The world of the game is represented using Room
s.
Room<DefaultGameState> mainHold {};
// define room information here...
gameRunner.addStartingRoom("main hold", mainHold);
Room
s have Event
s, which are run on entering a Room
. We can insert a basic text Event
, such as a description, using Room.setDescription
.
mainHold.setDescription("You are sitting in a passenger "
"chair in a dingy space freighter. As you look out the viewport, "
"you see the bright starlines of hyperspace flow around you.", "description");
The second argument sets a name for the Event
which is unique to the Room
. If a name is not specified, it is set to "description"
by default.
Event
s are actually wrappers for functions that take in the current Room
and GameState
information, and a bool
marking success and a text output. Here we peel back the abstraction layer to set a second text description more explicitly:
EventFunc<DefaultGameState> descriptionEvent = [](auto& room, auto& state) {
return make_pair(true, "You look around and see two other people. "
"One is a man, and the other is a woman.");
};
mainHold.events.emplace("description2", Event{descriptionEvent});
This allows us to have more complex Event
s which may modify GameState
as well as Room
information (such as available Event
s, Action
s, and connected Room
s).
Room
s also have Action
s. These are run when the player inputs a command with the starting word as the Action
's name. Action
s are specified like Event
s, except they can also take arguments. They have a similar helper function for defining basic text-based actions:
mainHold.setTextAction("Who would you like to greet?", "greet",
{{"man", "The man looks up and smiles at you. \"Bored yet?\""},
{"woman", "The woman makes eye contact with you but does not respond."}});
This is enough for a basic game. If we start the game, we can can see our two Event
texts and interact with the two NPC Action
s.
You are sitting in a passenger chair in a dingy space freighter. As you look out the viewport, you see the bright starlines of hyperspace flow around you.
You look around and see two other people. One is a man, and the other is a woman.
Currently available actions:
greet
go
save
load
quit
-----
> greet man
The man looks up and smiles at you. "Bored yet?"
...
-----
> greet woman
The woman makes eye contact with you but does not respond.
...
-----
>
Now let's say we want to be able to poke the woman after her lack of response. To do this, we can write out the Action
in its expanded form. This also allows us to create different cases for no arguments and invalid ones.
ActionFunc<DefaultGameState> talkAction =
[](auto& room, auto& state, vector<string> args) {
if (args.empty()) {
return make_pair(true, "Who do you want to greet?");
}
else if (args[0] == "man") {
// ...
}
else if (args[0] == "woman") {
// ...
}
return make_pair(true, "You can't greet this!");
};
mainHold.actions.insert_or_assign("greet", Action{talkAction});
Then we can define the added Action
as follows:
else if (args[0] == "woman") {
room.setTextAction("Who would you like to poke?", "poke",
{{"woman", "The woman glares at you."}});
return make_pair(true,
"The woman makes eye contact with you but does not respond.");
}
Gives output:
-----
> greet woman
The woman makes eye contact with you but does not respond.
Currently available actions:
poke
nod
greet
go
save
load
quit
-----
> poke woman
The woman glares at you.
...
-----
>
These added actions can also similarly modify the actions. For instance, we can add an action that removes itself.
else if (args[0] == "man") {
ActionFunc<DefaultGameState> nodAction =
[](auto& room, auto& state, vector<string> args) {
room.actions.erase("nod");
return make_pair(true, "You nod. "
"\"I knew it would happen eventually,\" he chuckles.");
};
room.actions.insert_or_assign("nod", Action{nodAction});
return make_pair(true, "The man looks up and smiles at you. \"Bored yet?\"");
}
Gives output:
-----
> greet man
The man looks up and smiles at you. "Bored yet?"
Currently available actions:
poke
nod
greet
go
save
load
quit
-----
> nod
You nod. "I knew it would happen eventually," he chuckles.
Currently available actions:
poke
greet
go
save
load
quit
-----
>
Note that these modifications are not currently persistent in the latest version of TBA. For now, information which cannot be lost on reloading the game should be stored in the GameState
, which we will demonstrate later.
Finally, Room
s can connect to other Room
s. We can easily add a new Room
and connect it to an existing Room
s.
Room<DefaultGameState> cockpit {};
Room<DefaultGameState> engineRoom {};
Room<DefaultGameState> cargoHold {};
cockpit.setDescription("You enter the cockpit. "
"The pilot is leaning back at her chair.");
// define rooms information here...
gameRunner.addConnectingRoom("up", "cockpit", cockpit, "down");
gameRunner.addConnectingRoom("left", "engine room", engineRoom, "right");
gameRunner.addConnectingRoom("left", "cargo hold", cargoHold, "back", "engine room");
The first argument specifies the direction to the new Room
, the second argument is the name of the new Room
, and the third argument is the Room
object itself. If we want the connection to be bidirectional, we can optionally specify the reverse direction. The final argument is the old Room
which is being connected to; it is the current Room
by default.
You can also create connections between existing Room
s by modifying the GameRunner.rooms
hash table. Here we define a one-way tunnel from the cargo hold to the cockpit:
cargoHold.connections.insert_or_assign("forward", "cockpit");
Note that Room
s are passed by value. It is best to define all Room
information before adding it if possible; if you desire to modify a Room
after, you can look it up using its name in GameRunner.rooms
.
The go <roomName>
action moves the player from one Room
to another. We can add on additional functionality to go
by defining our own Action
with the same name.
mainHold.setTextAction("", "go",
{{"up", "You climb up the ladder to the cockpit."}});
Gives output:
-----
> go up
You climb up the ladder to the cockpit.
You enter the cockpit. The pilot is leaning back at her chair.
Moved up.
Currently available actions:
...
-----
>
We can also use this to block the player from entering a Room
, perhaps until some precondition is met. For instance, let's say that the tunnel from the cargo hold to the cockpit is blocked by a stowaway droid. Trying to go this way leads you to encounter the droid, and you cannot pass until you have neutralized the droid. We will store this information in the GameState
.
gameRunner.state.flags.insert(make_pair("is stowaway alive", true));
ActionFunc<DefaultGameState> cargoGoAction =
[](auto& room, auto& state, vector<string> args) {
if (!args.empty() && args[0] == "forward") {
// triggers only on going forward, and when stowaway is not dead
bool isStowawayAlive = get<bool>(state.flags.at("is stowaway alive"));
if (isStowawayAlive) {
ActionFunc<DefaultGameState> attackAction =
[](auto& room, auto& state, vector<string> args) {
room.actions.erase("attack");
state.flags.insert_or_assign("is stowaway alive", false);
return make_pair(true, "You blast the droid in its face."
"It falls over and dies.");
};
room.actions.insert_or_assign("attack", Action{attackAction});
return make_pair(false, "You try to crawl forward, "
"but you see a stowaway droid blocking the path!");
}
else {
return make_pair(true, "You step past the burnt remains of "
"the droid and make your way up.");
}
}
return make_pair(true, "You exit the cargo hold.");
};
cargoHold.actions.insert_or_assign("go", Action{cargoGoAction});
Gives output:
-----
> go forward
Action failed: You try to climb forward, but you see a stowaway droid blocking the path!
Currently available actions:
attack
go
save
load
quit
-----
> attack
You blast the droid in its face. It falls over and dies.
Currently available actions:
go
save
load
quit
-----
> go forward
You step past the burnt remains of the droid and make your way forward.
You enter the cockpit. The pilot is leaning back at her chair.
Moved forward.
Currently available actions:
greet
go
save
load
quit
-----
>
As can be seen above, DefaultGameState.flags
provides an unordered_map
of string
to variant<bool, int, string>
, which you allows you to easily define your own game state without providing your own GameState
class.
For greater flexibility, you can also define your own GameState
with additional member variables. You must provide currentRoom
and gameEnd
variables, as well as serializing and deserializing functions.
You can also define your own GameTalker
concept. It must take and parse player input, and store an input history.
The text-based adventure game is a well-trodden genre. Our goal with this library is to make use of modern C++ features and techniques in order to make programming such a game easy and intuitive for the game developer, while also being extensible and flexible. To do this, we make use of generic programming and also take a functional approach to game events.
The GameRunner
is a class which owns all the game information and has the primary functions for running the game. This RAII approach allows for all resources to be managed on the lifetime of the GameRunner
.
To conform to this paradigm, Room
s are stored as an unordered_map
from RoomName
s (which are string
s) to Room
s. Because Room
s also contain a unordered_map
of Direction
s to Room
s, this thus indirectly forms an adjacency list. Standard library functions make finding and accessing simple, and we provide helper functions to more easily create and assign new Room
s and their connections.
The GameRunner
is a templated class, taking GameState
and GameTalker
concepts. This allows developers to define their own GameState
and GameTalker
classes as needed. GameTalker
does input handling, and GameState
stores persistent game information (and provides serializing functionality); both are things which are desirable to customize.
For more quick and dirty setup, DefaultGameState
and DefaultGameTalker
are provided. DefaultGameState
provides a basic flags
container of type unordered_map<string, variant<bool, int, string>>
to allow for flexible storage of state. This is effectively a bandaid solutions which pushes things onto the runtime, so a custom GameState
is preferable for cleaner code on longer-term projects.
The GameRunner
also contains functions for handling game events, moving between Room
s, and saving/loading the game.
To allow for the greatest flexibility, we treat game events as function
s which may be stored in Room
s at will. These function
s take the current Room
and the GameState
by reference so that they can easily modify both. We split these up into Event
s, which are run on entering a Room
; and Action
s, which take string arguments and are run by the player. In each Room
, Action
s are stored in a unordered_map
; Event
s are stored in a custom ordered hash map so that they can be both easily accessed and iterated through in order.
The developer can therefore define their own game events as lambda functions and then store them in the relevant Room
. Repeated events/actions can of course be copied to each relevant Room
. The library provides functions to generate text-only Event
s and Action
s (the latter taking an unordered_map
to deal with string arguments). The programmer can similarly define their own functions to generate often-used Event
s and Action
s. For example, in a combat-heavy game, the developer can create a function that generates a function
which handles the intricacies of combat.
Finally, we provide functionality to save and load the game. The DefaultGameState
provides two serializing formats: a simple text format, and a simple binary format. A user-provided GameState
can also (and must) provide its own save formats (and associated serializing/deserializing functionality).
In a text-based adventure game, there is of course not really much of a performance bottleneck on modern systems. Everything executes near-instantaneously under normal conditions.
One possible bottleneck is saving and loading the game, since that requires generation/parsing and writing/reading. Even though this time always remains small for usual GameState
s that we'd expect for a simple adventure game, we decided to push this to its limits.
Although not necessarily realistic in this scenario, it is still directly applicable to real situations that real games face: games must save/load and send/receive large amounts of data. When this gets large and under performance constraints, this makes the particulars of how the savefile is formatted and parsed paramount.
We wrote custom serialization functions for text and binary (which are also provided in DefaultGameState
) alongside functions which use JSON and XML (from the RapidJSON and RapidXML libraries). For N iterations, a test function inserts 1 random int
value, 1 alternating bool
value, and 1 random string
value into the flags
hash table. We did this for multiples of 2, from N = 2 up to N = 223 ~ 8 million. This creates files of up to ~500 MB for the more efficient save formats, and over ~2 GB for less efficient save formats.
Let us first take a look at the raw results:
We appear to see a curved growth pattern on XML. If we remove XML from the dataset, we can see that the growth pattern in the other save formats is roughly linear.
As might be expected, binary performs the best, followed by text, followed by JSON. The XML library performs surprisingly poorly, perhaps a combination of the increased file size and the more complex code generation/parsing which needs to be done.
If we turn this to a log-log plot, all the curves are linear, which suggests that all of them are polynomial scaling relations. (Combining this with the results above, this suggests that perhaps simple text, binary, and JSON are all linear, whereas XML may have a power-law curve with exponent greater than 1.)
We can separate the results out to saving and loading:
Interestingly, XML seems to perform much worse on saving compared to loading (relative to the other formats). Perhaps the code generation is more difficult than the parsing for XML. It may also be the case that XML may have been hit by certain performance costs due to the library details, such as custom allocator pools for cstrings. But RapidJSON also does something very similar here, so it seems more likely that this has to do with the format itself. Without further testing, however, we cannot rule out the role of implementation details of the two libraries.
Our current serialization functionality is limited to the GameState
. This unfortunately means that the state of the Room
s is not persistent. While this may be an acceptable environment to develop in, we believe that our library could become much more powerful if the Room
s, their connections, and their associated Event
s/Action
s can be stored at will.
The primary limiting factor here is that std::function
is not serializable. This is because certain information, such as the captured closure values, are stored in private class member fields. If we define our own function
implementation, we should be able serialize the function pointer (though we may have to disable ASLR for this to work) and any closure values. This appears to have been done before. Though the code has not been made public, it should still be a feasible process. This change would allow the game programmer to fully express changes in Event
/Action
functions without having to rely on checking the GameState
or dealing with issues of persistence.
Another issue we ran into in our implementation was Clang's limited support for C++20 features. In particular, we were hampered by the lack of module partitions, which led to our code and build chain being messier than they could have been. We also could have made use of the ranges library for string tokenization. Once these features are implemented in Clang/libc++, we will be able to make much nicer and cleaner code.
The following is a simplified reference manual for using the TBA library. It explains important concepts, types, and classes for a game developer using the library. For more detailed information, please review the module file for definitions, and the tutorial for usage.
GameTalker
: handles and tokenizes input, and stores a history of inputGameState
: stores persistent game state, including whether the game has ended and the current room; must provide serializing/deserializing functions
EventFunc<GameState>
: anstd::function
which takes aRoom
and aGameState
by reference, and returns abool
success flag and astring
output (to be printed)ActionFunc<GameState>
: anstd::function
which takes aRoom
and aGameState
by reference, as well as avector<string>
of arguments, and returns abool
success flag and astring
outputDirection
: astring
name of a direction for aRoom
connectionRoomName
: astring
name of aRoom
Format
: astring
name of a save format
Event<GameState>
: wrapper for anEventFunc
; intended to be run on entering aRoom
Event()
: constructor takes anEventFunc
Action<GameState>
: wrapper for anActionFunc
; intended to be run on player inputAction()
: constructor takes anActionFunc
EventMap<GameState>
: custom ordered hash map forstring
s toEvent
smap
:unordered_map
of keys and valuesorder
:vector
of keysadd()
: insert with replacement; returns true on new insertionemplace()
: insert without replacement; returns true on insertionerase()
: deletes entry specified by key if it exists
Room<GameState>
: stores room informationconnections
:unordered_map
ofDirection
s toRoomName
sevents
:EventMap
of event names toEvent
sactions
:unordered_map
of action names toAction
ssetDescription()
: creates anEvent
which outputs specified description textsetTextAction()
: creates anAction
which outputs specified text on specified input arguments
GameRunner<GameTalker, GameState>
: owns all game information and provides game running functionalitystate
: aGameState
which is used to store game staterooms
: anunordered_map
ofRoomName
s toRoom
s, which stores allRoom
s in the gamerunGame()
: starts the game; handles the game loopgetCurrentRoom()
: returns reference to the currentRoom
addStartingRoom()
: stores the givenRoom
and sets it as the currentRoom
addConnectingRoom()
: stores the givenRoom
and connects it to a specifiedRoom
via givenDirection
(s)goNextRoom()
: moves player toRoom
in givenDirection
as specified by the currentRoom
'sconnections
setSaveState()
: sets the saveFormat
and whether it is a binary or not
DefaultGameState
: aGameState
class which is bundled with the libraryflags
: anunordered_map
ofstring
tovariant<bool, int, string>
which can be used to store custom game stategameEnd
: whether the game has ended or notcurrentRoom
: the name of the currentRoom
Basic directory structure:
lib/
: library source codetutorial_game/
: game using librarytest_game/
: testing and data collection/analysis using libraryrapidjson/
andrapidxml/
: JSON and XML parsing libraries, respectively, largely used for testing purposes
The library and tutorial game are compiled separately, as is the test program. To build, run make
in each directory. Please make sure you have Clang 11 installed.
I recommend using the clangd extension for debugging and intellisense on VS Code. With the current setup, errors still show up on clangd, but it largely works once you do an initial compilation.
If you are using VS Code, I also recommend adding ""*.cppm": "cpp"
to your .vscode/settings.json
.