A Configuration and Tweak System
Setting up a configuration system for a game sounds like a trivial task. What exactly is there to store beyond some graphics, sound and controller information? For the final released game, those items cover most of the needs of a simple game, but during development it is handy to store considerably more information. Simple items such as auto loading a specific level you are working on, if bounding boxes are displayed and other items can greatly speed debugging. Of course it is possible to write different systems, one for the primary configuration and another for tweakables, but that is a duplication of effort and not required. Presented in this article is configuration system which supports both standard configuration and development time configuration.
This article builds on the CMake environment presented in the articles
here and extends from the updated version presented with the SIMD articles
here. The code for the article can be found
here starting at the tag 'ConfigSystem' and contained in the "Game" directory.
Goals
The primary goals of the configuration system are fairly simple: provide configuration serialization without getting in the way of normal game programming or requiring special base classes. While this seems simple enough, there are some tricky items to deal with. An example could be the choice of a skin for the in-game UI. The configuration data will be loaded when the game starts up in order to setup the main window's position, size and if it is fullscreen or not, but the primary game UI is created later after the window is setup. While it is possible to simply set a global flag for later inspection by the UI, it is often preferable to keep the data with the objects which use them. In order to solve this sort of delayed configuration, the system maintains a key value store of the configuration such that access is available at any point during execution.
Keeping the solution as simple and non-intrusive as possible is another important goal. It should take no more than a minute or two to hook up configuration file persistence without requiring multiple changes. If it is needed to change a local variable to be attached to a configuration item it should not be required to change the local to a global or move it to a centralized location. The system should work with the local value just as well as member variables and globals in order to remain non-intrusive.
Finally, while not a requirement of the configuration data directly, it should be possible to control and display the items from within the game itself. For this purpose, a secondary library is supplied which wraps the open source library AntTweakBar (
http://anttweakbar.sourceforge.net/doc/) and connects it to variables in the game. This little library is a decent enough starting point to get going after hacking it a bit to fit into the CMake build environment. Eventually the library will likely be replaced with the chosen UI library for the game being written as part of these articles. For the time being though, it serves the purpose as something quick to use with some nice abilities.
Using the System
A basic overview of using the configuration system is presented through a series of small examples. For full examples the current repository contains an application called XO which is the beginnings of a game and includes a number of useful additions beyond the scope of this article. It currently builds off of SFML 2.0 and includes a test integration of the 'libRocket' UI framework in addition to a number of utilities for logging and command line argument parsing. For more detailed examples, please see the application being created.
The Singleton
The configuration system requires a singleton object to store the in memory database. While there are many ways to implement singletons, the choice of singleton style here is to use a scoped object somewhere within the startup of the game in order to explicitly control creation and shutdown. A very simple example of usage:
#include
int main( int argc, char** argv )
{
Config::Initializer configInit( "config.json" );
return 0;
}
Compared to other solutions, such as the Pheonix singleton, there are no questions as to the lifetime of the object which is very important in controlling when data is loaded and saved.
Simple Global Configuration Items
The first use of the configuration system will show a simple global configuration item. This will be shown without any of the helpers which ease usage in order to provide a general overview of the classes involved. The example is simply a modification of the standby "Hello World!" example:
#include
#include
#include
std::string gMessage( "Hello World!" );
Config::TypedItem< std::string > gMessageItem( "HelloWorld/Message", gMessage );
int main( int argc, char** argv )
{
Config::Initializer configInit( "HelloWorld.json" );
// Initialize the message from the configuration.
// If the item does not exist, the initial value is retained.
Config::Instance->Initialize( gMessageItem );
std::cout << gMessage;
// Update the message in the configuration registry.
// This example never changes the value but it is
// possible to modify it in the json file after
// the first run.
Config::Instance->Store( gMessageItem );
return 0;
}
When you run this the first time, the output is as expected: "Hello World!". Additionally, the executable will create the file "HelloWorld.json" with the following contents:
{
"Registry" :
[
{
"Name" : "HelloWorld\/Message",
"Value" : "Hello World!"
}
]
}
If you edit the value string in the JSON file to be "Goodbye World!" and rerun the example, the output will be changed to the new string. The default value is overwritten by the value read from the configuration file. This is not in itself all that useful, but it does show the basics of using the system.
Auto Initialization and Macros
In order to ease the usage of the configuration system there is a utility class and a set of macros. Starting with the utility class, we can greatly simplify the global configuration item. Rewrite the example as follows:
#include
#include
#include
std::string gMessage( "Hello World!" );
Config::InitializerList gMessageItem( "HelloWorld/Message", gMessage );
int main( int argc, char** argv )
{
Config::Initializer configInit( "HelloWorld.json" );
std::cout << gMessage;
return 0;
}
The
InitializerList class works with any global or static item and automates the initialization and storage of the item. This system uses a safe variation of static initialization in order to build a list of all items wrapped with the
InitializerList class. When the configuration initializer is created and the configuration is loaded, items in the list are automatically configured. At shutdown, the configuration data is updated from current values and as such the user does not need to worry about the global and static types when the
InitializerList is in use.
Using the static initializer from a static library in release builds will generally cause the object to be stripped as it is not directly referenced. This means that the item will not be configured in such a scenario. While this can be worked around, it is not currently done in this implementation. If you require this functionality before I end up adding it, let me know and I'll get it added.
A further simplification using macros is also possible. The two lines defining the variable and wrapping it with an initializer list are helpfully combined into the
CONFIG_VAR macro. The macro takes the type of the variable, the name, the key to be used in the registry and the default starting value. The macro expands identically to the two lines and is not really needed, but it does make things more readable at times.
Scoped Configuration
Other forms of configuration such as local scoped variables and members can be defined in similar manners to the global items. Using the
InitializerList class is safe in the case of locals and members as it will initialize from the registry on creation and of course store to the registry on destruction. The class automatically figures out if the configuration is loaded and deals with the referenced item appropriately. So, for instance the following addition to the example works as intended:
#include
#include
#include
CONFIG_VAR( std::string, gMessage, "HelloWorld/Message", "Hello World!" );
int main( int argc, char** argv )
{
Config::Initializer configInit( "HelloWorld.json" );
CONFIG_VAR( int32_t, localVar, "HelloWorld/localVar", 1234 );
std::cout << gMessage << " : " << localVar;
return 0;
}
The currently configured message will be printed out followed by ": 1234" and the configuration JSON will reflect the new variable. Changing the variable in the configuration file will properly be reflected in a second run of the program and if you changed the value within the program it would be reflected in the configuration file.
Class Member Configuration
Configuring classes using the system is not much more difficult than using globals, the primary difference is in splitting the header file declaration from the implementation initialization and adding some dynamic key building abilities if appropriate. Take the following example of a simple window class:
class MyWindow
{
public:
MyWindow( const std::string& name );
private:
const std::string mName;
Math::Vector2i mPosition;
Math::Vector2i mSize;
};
MyWindow::MyWindow( const std::string& name )
: mName( name )
, mPosition( Math::Vector2i::Zero() )
, mSize( Math::Vector2i::Zero() )
{
}
In order to add configuration persistence to this class simply make the following modifications:
class MyWindow
{
public:
MyWindow( const std::string& name );
private:
const std::string mName;
CONFIG_MEMBER( Math::Vector2i, mPosition );
CONFIG_MEMBER( Math::Vector2i, mSize );
};
MyWindow::MyWindow( const std::string& name )
: mName( name )
, CONFIG_INIT( mPosition, name+"/Position", Math::Vector2i::Zero() )
, CONFIG_INIT( mSize, name+"/Size", Math::Vector2i::Zero() )
{
}
Each differently named window will now have configuration data automatically initialized from and stored in the configuration registry. On first run, the values will be zero'd and after the user moves the windows and closes them, they will be properly persisted and restored next use.
Once again, the macros are not required and are simply a small utility to create the specific type instance and the helper object which hooks it to the configuration system. While it would be possible to wrap the actual instance item within the configuration binding helpers and avoid the macro, it was preferable to leave the variables untouched so as not to affect other pieces of code. This was a tradeoff required to prevent intrusive behavior when converting items to be configured.
Adding New Types
Adding new types to the configuration system is intended to be fairly simple. It is required to add a specialized template class for your type and implement three items: the constructor, a load and save function. The following structure will be serialized in the example:
struct MyStruct
{
int32_t Test1;
uint32_t Test2;
float Test3;
std::string Test4;
};
While prior examples only dealt with single types, it is quite simple to deal with composites such as this structure given the underlying nature of the JSON implementation used for serialization; the outline of the implementation is as follows:
#include
namespace Config
{
template<>
struct Serializer< MyStruct > : public Config::Item
{
Serializer( const std::string& key ) : Item( key ) {}
protected:
bool _Load( MyStruct& ms, const JSONValue& inval );
JSONValue* _Save( const MyStruct& inval );
};
}
That is everything you need to do to handle your type, though of course we need to fill in the
_Load and
_Save functions which is also quite simple. The
_Load function is:
inline bool Serializer< MyStruct >::_Load( MyStruct& ms, const JSONValue& inval )
{
if( inval.IsObject() )
{
const JSONObject& obj = inval.AsObject();
ms.Test1 = (int32_t)obj.at( L"Test1" )->AsNumber();
ms.Test2 = (uint32_t)obj.at( L"Test2" )->AsNumber();
ms.Test3 = (float)obj.at( L"Test3" )->AsNumber();
ms.Test4 = string_cast< std::string >( obj.at( L"Test4" )->AsString() );
return true;
}
return false;
}
Obviously this code does very little error checking and can cause problems if the keys do not exist. But other than adding further error checks, this code is representative of how easy the JSON serialization is in the case of reading value data. The save function is just as simplistic:
inline JSONValue* Serializer< MyStruct >::_Save( const MyStruct& inval )
{
JSONObject obj;
obj[ L"Test1" ] = new JSONValue( (double)inval.Test1 );
obj[ L"Test2" ] = new JSONValue( (double)inval.Test2 );
obj[ L"Test3" ] = new JSONValue( (double)inval.Test3 );
obj[ L"Test4" ] = new JSONValue( string_cast< std::wstring >( inval.Test4 ) );
return new JSONValue( obj );
}
This implementation shows just how easy it is to implement new type support thanks to both the simplicity of the library requirements and the JSON object serialization format in use.
The JSON library used works with L literals or std::wstring by default, the string_cast functions are simple helpers to convert std::string to/from std::wstring. These conversions are not code page aware or in anyway safe to use with real unicode strings, they simply trim/expand the width of the char type since most of the data in use is never intended to be presented to the user.
The Tweak UI
As mentioned in the usage overview, the UI for tweaking configuration data is currently incorporated into the beginnings of a game application called XO. The following image shows a sample of configuration display, some debugging utility panels and a little test of the UI library I incorporated into the example. While this discussion may seem related to the configuration system itself, that is only a side affect of both systems going together. There is no requirement that the panels refer only to data marked for persistence, the tweak UI can refer to any data persisted or not. This allows hooking up panels to debug just about anything you could require.
The 'Debug Panel' is the primary panel which shows the current position and size of the application window, if the window is fullscreen or not and the fullscreen mode selection if you wish to change to fullscreen. Below that basic information are buttons which allow you to toggle open or closed the 'UI Debugger' and the 'Joystick Debugger' panels. The panels are also integrated into the configuration system such that they remember their display state, position and size information. And finally, if compiled with the CMake controlled value
ENABLE_DEVBUILD turned off, all of the debugging windows compile out of the build removing reliance on the AntTweakBar library.
The panels are simply examples and won't be discussed. How to create your own panels and hook them up will be gone over briefly in the remainder of the article.
A Basic Panel
Creating a basic debug panel and hooking it up for display in the testbed is fairly easy, though it uses a form of initialization not everyone will be familiar with. Due to the fact that AntTweakBar provides quite a number of different settings and abilities, I found it preferable to wrap up the creation in a manner which does not require a lot of default arguments, filling in structures or other repetitive details. The solution is generally called chained initialization which looks rather odd at first but can reduce the amount of typing in complicated initialization scenarios. Let's create a simple empty panel in order to start explaining chaining:
TweakUtils::Panel myPanel = TweakUtils::Panel::Create( "My Panel" );
If that were hooked into the system and displayed it would be a default blue colored panel with white text and no content. Nothing too surprising there, but let's say we want to differentiate the panel for quick identification by turning it bright red with black text. In a traditional initialization system you would likely have default arguments in the
Create function which could be redefined. Unfortunately given the number of options possible in a panel, such default arguments become exceptionally long chains. Consider that a panel can have defaults for position, size, color, font color, font size, iconized or not, and even a potential callback to update or read data items it needs to function; the number of default arguments gets out of hand. Chaining the initialization cleans things up, though it looks a bit odd as mentioned:
TweakUtils::Panel myPanel = TweakUtils::Panel::Create( "My Panel" )
.Color( 200, 40, 40 )
.DarkText();
If you look at the example and say "Huh???", don't feel bad, I said the same thing when I first discovered this initialization pattern. There is nothing really fancy going on here, it is normal C++ code.
Color is a member function of
Panel with the following declaration:
Panel& Color( uint8_t r, uint8_t g, uint8_t b, uint8_t a=200 );
Because
Panel::Create returns a
Panel instance, the call to
Color works off the returned instance and modifies the rgba values in the object, returning a reference to itself which just happens to be the originally created panel instance.
DarkText works off the reference and modifies the text color, once again returning a reference to the original panel instance. You can chain such modifiers as long as you want as long as they all return a reference to the panel. At the end of the chain, the modified panel object is assigned to your variable with all the modifications in place. When you have many possible options, this chaining is often cleaner than definition structures or default arguments. This is especially apparent with default arguments where you may wish to add only one option but if that option were at the end of the defaults, you would have to write all the intermediates just to get to the final argument.
Adding a Button
With the empty panel modified as desired, it is time to add something useful to it. For the moment, just adding a simple button which logs when it is pressed will be enough. Adding a button also uses the initializer chaining though there is one additional requirement I will discuss after the example:
TweakUtils::Panel myPanel = TweakUtils::Panel::Create( "My Panel" )
.Color( 200, 40, 40 )
.DarkText();
.Button( "My Button", []{ LOG( INFO ) << "My Button was pressed."; } ).End();
Using a lambda as the callback for button press, we simply log event. But you may be wondering what the
End function is doing. When adding controls to the panel the controls don't return references to the panel, they return references to the created control. In this way, if it were supported, you could add additional settings to the button such as a unique color. The initialization chain would affect the control being defined and not the panel itself even if the functions were named the same. When you are done setting up the control,
End is called to return the owning
Panel object such that further chaining is possible. So, adding to the example:
static uint32_t sMyTestValue = 0;
TweakUtils::Panel myPanel = TweakUtils::Panel::Create( "My Panel" )
.Color( 200, 40, 40 )
.DarkText();
.Button( "My Button", []{ LOG( INFO ) << "My Button was pressed."; } ).End()
.Variable< uint32_t >( "My Test", &someVariable ).End();
Adds an editable
uint32_t variable to the panel. Panel variables can be most fundamental types,
std::string and the math library
Vector2i,
Vector3f and
Quaternionf types. With the
Vector3f and
Quaternionf types, AntTweakBar displays a graphical representation of direction and orientation respectively which helps when debugging math problems. Further and more detailed examples exist in the XO application within the repository.
The current implementation of the TweakUI is fairly preliminary which means that it is both dirty and subject to rapid change. As with the configuration system, it gets the required job done but it is missing some features which would be nice to have.
An additional note, in order to prevent direct dependencies, the Vector and Quaternion types are handled in a standalone header. If you do not desire them, simply do not include the header and there will be no dependencies on the math library.
Adding a Panel to the Screen
Adding a panel to the system can be done in a number of ways. The obvious method is to create a panel, hook up a key in the input processing and toggle it from there. This is perfectly viable and is in fact how the primary 'Debug Panel' works. I wanted something a little easier though and as such I added a quick (and dirty) panel management ability to the application framework itself. The function
RegisterPanel exists in the
AppWindow class where you can hand a pointer to your panel over and it will be added to the 'Debug Panel' as a new toggle button. At the bottom of the 'Debug Panel' in the screen shot, you see the 'Joystick Debugger' button, that button is the result of registering the panel in this manner. It is a quick and simple way to add panels without having to bind specific keys to each panel.
Currently the 'Debug Panel' is bound to the F5 function key and all panels default to hidden. Pressing F5 will open/close the panel, which the first time will be very small in the upper left corner. Move it, resize it and exit the application. The next run it will be shown or not at the location and size of the last exit.
Additionally worth noting, the current panel creation may change to better integrate with other sections of code. The chained initialization is likely to remain unchanged but the returned types may be modified a bit.
Conclusion
This article provides a brief overview of the configuration system I am using in my open source testbed tutorials project. It is not a perfect solution, nor is it actually intended to be. It sets out to solve a couple fairly simple goals with a few less-common items thrown in, all without trying to be 100% complete for every possible usage. Only currently in-use items are fairly well tested and debugged and additions will be made only as needed or requested.
The supplied tweak UI is a good starting point until such time as you have your own UI items such as graphics and sound options. Even after such items exist, the live tweak abilities of the debug panels are highly useful since a panel can be added quite quickly to test modified values during live gameplay.
Of course, it is always important to reiterate that the code is live and undergoing modifications continually. Some of the information presented here may be out of date (and eventually updated) but hopefully with the overview it won't be too far off.
Be warned, the codebase now requires CMake 2.8.11. I recently switched it to use 'target usage requirements' provided in the latest release. A CMake part 5 is upcoming which covers how it simplifies life using CMake in general..