This is part 2 of the multipart series Trap Labs Code Design and Architecture Series.
As mentioned earlier in the series, starting your code with entities is usually the safe choice. If you go back and look at the UML of Trap Lab’s high level diagram you'll see that TLMap sits at the bottom of the dependency list. Entities are almost pure data structure like classes that has little logic, and they usually sit on the bottom of the dependency hierarchy of your software. For example, the cannon from space invaders in its simplest entity form would be:
struct Cannon
{
float position;
}
Other members you might consider adding (depending on your version of space invaders) are attributes like damage, speed, etc.
TLMap is mostly comprised of map entity data structure and classes. Some behavioral classes that operate on these entities such as pathfinding are also included in TLMap.
Map elements are entities like visual items and interactive items like traps. Let's first understand how Trap Labs’ maps work. Here's a breakdown of what’s inside a map:
All map elements (including traps) are derived off a base entity struct called MapElement
:
struct MapElement : Serializable
{
MapElementID id;
uint32_t zOrder;
bool hidden;
…
}
Your implementation of MapElement
may vary. For example you may choose to include grid position if all your elements are position based. I didn’t because I have map elements that are not positions based, so I have a derivative struct called TileMapElement
that is used for all element that are grid based.
Notice MapElement
is derived off Serializable
. Serializable
is an interface that you can override the serialization function. For example:
struct TileMapElement : MapElement
{
Point position;
void Serialize(std::ostream *outStream) override;
...
}
This way, whenever you create a new element that you wish to export, you can just push it to something like:
std::vector<std::unique_ptr<MapElement>> mapElements;
Using MapElement
as the base class allows you to generalize the serialization/deserialization of each element. When import/export is happening the import and export class would simply loop over the element list:
class MapElementExporter
{
public:
MapElementExporter(const std::vector<std::unique_ptr<MapElement>> &mapElements);
void ExportToStream(std::ostream &stream)
{
for (auto &element : m_mapElements)
element->Serialize(stream);
}
...
private:
const std::vector<std::unique_ptr<MapElement>> &m_mapElements
...
}
Of course this is a really dumbed down version of the exporter, but I hope you get the idea.
Similarly, the design for MapEvent
is very similar. Map events are composed of a trigger and an action. In TLMap they are purely entity structs that define what the trigger and actions are, and the interactors in TLApp will translate them into behavioral classes. An example of an writing an event would look something like this:
MapEventData eventData;
auto trigger = std::make_unique<ArrivedAtTrigger>();
trigger->position = Point(7,7);
auto action = std::make_unique<OpenDoorAction>();
action->doorNumber = 7;
eventData->trigger = std::move(trigger);
eventData->action = std::move(action);
eventData->Serialize(outStream);
...
The code should be self-explanatory. The map event simply says, “When arrived at point (7,7), open door number 7”. I will go over how the actual event system works in part 5 as I go over the game engine, but for the map maker this is sufficient for the user to build map events.
Trap Labs’ maps are loadable. You can create map through the map maker, piece together your rooms and obstacle courses like Lego, and export the map into binary file that can be loaded by the game during runtime.
There is a secret sauce to successfully building a map maker that uses an entities library, and that is to separate your map elements into entities and interactors. Interactors are classes that define behavior (often for the entities). For Trap Labs, the entities belong in the TLMap library, the interactors belong in the game engine (TLApp).
Why is this the secret sauce? It allows us to separate element properties from their behaviors such that the map maker only cares about the properties, and the game engine can focus on the behaviors. The map maker does not need to know any of the map element behaviors in order for it function. If you smeared the properties and behaviors into a single class, which is what most people do, then not only will the map maker end up having dependencies that it doesn’t need, it will also end up using classes with many unused methods. This is a big violation of the Interface Segregation Principle (ISP), which states “no client should be forced to depend on methods it does not use”.
Remember from previous part I said to write down as many features and requirements as you can? If you wrote down map maker, then you need to be aware that any modular physical elements within your game should probably use the entity/interactor model. Hell, personally I would recommend always going with this model just to be on the safe side. It’s a little extra work, but it ensures extensibility in many aspects.
Let’s take a look at the spike trap from Trap Labs as an example. The standard spike trap triggers after x secs after the player walks it. The entity looks like this:
struct SpikeTrapEntity : Serializable
{
float startDelay;
float liveDuration;
float offDuration;
Point gridPosition;
...
}
The actual SpikeTrap
interactor class is composed of an instance of the SpikeTrapEntity
, which contains the game loop’s update logic:
class SpikeTrap
{
public:
SpikeTrap(SpikeTrapEntity *entity);
void Update(float deltaT)
...
private:
SpikeTrapEntity *m_spikeTrap;
...
}
A beginner programmer’s intuition would be to combine them into a single class, because it makes sense right? Often good design and architecture is counter intuitive.
Firstly, import and export requires serialization of the entity, but serialization have zero dependency on the behavior of the actual element. SpikeTrap's behavior is irrelevant to how the element is being serialized and deserialized. Combining them into a single class would still work but you end up including unused behavioral methods, thus violating ISP. In addition, depending on how you are serializing, each serialization function may be different and require their own test. This way you isolate data construction and serialization in their own module, make it significantly easier to test and verify import/export operations.
Secondly, the map maker itself should have no dependency on the behavior of the spike trap either. The map maker simply allows you to place the map element and set its data fields. This way the map maker can just link against TLMap.
There is a limitation to the map mapper if made this way. I didn’t allow the map maker to live test the elements while in editor. The work flow is you build the map, then you play test it. You can certainly go for a live editing editor, but the design will have to be a lot more intricate. For me this approach was sufficient.
The last thing I want to talk about is to give you some hints on the import export process. I just used a binary file. You can certainly use a text data file like XML or JSON if it’s simpler for you or you want your users to be able to manually edit the raw map files.
The key to keeping import and export clean is to separate each type of entities’ import/export to their own classes. I already showed you an example MapElementExporter
above. I also have MapEventExporter
, TrapExporter
, DialogExporter
etc. This way you can write a centralized import/export class that is composed of these individual importer/exporters and plug them in as needed. This will allow you to indefinitely add and remove elements from the map in the future as you make changes to your game. In addition, if you run into import/export problems you can quickly isolate issues by simply unplugging the working modules.
===
That’s all I have to say on TLMaps. I hope this gave you some good guidelines on building an entities library and what kind of things to think about when building a game with a map maker. We now continue our journey into the multiplayer command engine in Part 3.