C++ as a scripting language
(sort of)
My new puzzle game Blockdown (Steam page) has its own game engine. Filament does all the heavy lifting for graphics, and the game doesn’t need much of an artist pipeline. It doesn’t have any real physics either. In fact the lack of gravity is essential, since the player rolls around on tiles that have all sorts of orientations:
The entire game is written in Orthodox C++, including the logic for all the puzzles. Even though I love embeddable languages like Lua and Wren, I decided not to use them. I worried that I would spend too much time building wrappers and writing glue code.
Moreover I knew that my puzzles would need to do a lot of vector math, and I really wanted to use shader-style syntax for that. Filament’s C++ math library already supports GLSL syntax, so why use something different?
Oh, and I didn’t build a level editor either. At this point, you might think I’m crazy: how could I possibly iterate and tweak each puzzle, if I’m constantly rebuilding the game?
Dynamic linking
My situation is one of those rare cases where dynamic linking is actually the perfect tool for the job. The game’s CMake file defines two targets: one for the game’s executable (which staticly links Filament and other dependencies) and one for a small library containing the puzzle specifications. Since the puzzle library is small, it compiles very quickly….so quickly that it feels like a script.
In the following video, you can see me edit a puzzle and watch the level reload in real time.
I don’t need to worry about manually loading a bunch of function pointers from the library, because there’s only one function that it exposes, get_level_specs()
. This function populates an array of “specifications”, one for each level in the game. Each specification is a small set of callback functions. For example, the prepare()
callback sets up the initial arrangement of tiles, and the animate()
callback executes in the game loop.
In non-production macOS builds, the game engine polls the timestamp of the library using stat()
. If it sees a new timestamp, it reopens the library, fetches the entry point, and calls it. It looks a bit like this:
int HotLoaderImpl::reload(LevelSpec* specs, GameServices* services) {
if (_dlhandle) {
(_dlhandle);
dlclose}
= dlopen(_dlpath.c_str(), RTLD_LOCAL | RTLD_LAZY);
_dlhandle if (!_dlhandle) {
("Unable to load level specs library: {}", dlerror());
error(EXIT_FAILURE);
exit}
= (GET_LEVEL_SPECS_CB) dlsym(_dlhandle, "get_level_specs");
_get_level_specs if (_get_level_specs == nullptr) {
("Unable to load level specs function.");
error(EXIT_FAILURE);
exit}
return _get_level_specs(specs, services);
}
In production builds of the game, the polling is disabled and the puzzles library can be built staticly to make it harder for people to hack the game.
One more thing. To optimize my workflow further, I use fswatch to look for changes in the source folder and invoke the build tool appropriately.
For example, the following command line watches the puzzles_src
folder for changes in C++ code. As soon as a change occurs, it rebuilds a CMake target called puzzles_dll
. The polling that occurs in the developer build of the game detects the new library and reloads it.
% fswatch -o puzzles_src | xargs -I {} \
--build .release -- puzzles_dll cmake
Overall I’m pretty happy with my approach to puzzle “scripting”…and yes, it’s not really scripting at all.
Game engine API
The callback functions that are defined in the puzzles library interact with the game engine via coarse-level API objects like Grid
, Player
, and GameServices
.
All of Blockdown’s API objects are strictly composed of pure virtuals. This is not a performance concern because I made sure that all API objects are coarse. For example, Grid
provides access to all the tiles in the level, but there is no Tile
API object. The tiles in Blockdown are numerous, so they are exposed in more of an ECS style, not as individual API objects.
Using only pure virtuals for the API forces me to keep private stuff out of headers. This achieves more than mere code cleanliness; it makes it easy to avoid “multiple definition” problems, given the way that the puzzles library is built.
Here’s what the GameServices
API looks like. Every other game class (e.g. Grid
) has a very similar style.
class GameServices {
public:
static GameServices* create();
static void destroy(GameServices*);
virtual Environment* environment() = 0;
virtual Grid* grid() = 0;
virtual Player* player() = 0;
virtual LocalSettings* settings() = 0;
virtual ~GameServices() = default;
};
The implementation classes are wholly defined inside the game engine, which takes much longer to build than the puzzles library. For example, an implementation class might look a bit like this:
class GameImpl : public GameServices {
public:
() { ... }
GameImpl~GameImpl() { ... }
* environment() final { ... }
Environment* grid() final { ... }
Grid* player() final { ... }
Player* settings() final { ... }
LocalSettings};
* GameServices::create() { return new GameImpl(); }
GameServicesvoid GameServices::destroy(GameServices* game) { delete game; }
That’s all for now, I hope you find my architecture interesting. And, I hope you’ll play the game too. 😀 🚀