Systems Code in Detail
I prepared a short video showcasing the code within the Unity project roughly taling about the same things like this explanation here.
That is a parasite. It is a sin. It was death we needed to die. An open knife, I knew we would jump into… and we jumped. We needed to be able to get data from about everywhere in the game so this was my solution to get connected to that data without coupling the progression system too tightly to game components.
Connecting is done on start-up in our progression driver (and tutorial driver but it is essentially copied until it is not. Tutorials and quests were one system until their requirements changed and it was better to split them. Yet they work very similiar and I may copied the progression driver). It is those simple two lambda functions. Honestly I am not proud about them but you can read that in the comment above. Oh and I like offensive programming. I like to shove myself error messages and stop execution rather than behaving as if nothing happend and wondering later why certain systems don’t work as expected. Don’t treat a bug as an exception where operation in a degressed state is needed. I don’t like to operate in degressed states. I do that way too often. I have migraine.
Anyway, our quest is the data struct containing everything we need. We needed both references to other quests as well as references to scene objects. That is unfortunate because in Unity you can’t have both. Hence we have the quest as
ScriptableObject saved as data object to disk and as serialised object saved inside the scene. That way we reference using the saved files but save the completion requirements, rewards, and prerequisites inside the wrapper.
In Unity this monster turns into an UX hell describing a quest. I mean it was fine without further tools for our limited scope but I would have invested into a quest and tutorial tool if we would have had more than the seventeen quests and tutorials.
Speaking of tutorials, they are kind of similar but they are not. They all are active by default and you can silently finish them with certain actions and never see most of them. The main reason was to make them as unobtrusive as possible. We tried to think of ways to detect when players are stuck but that ended up being a dead end, so instead it is only time based with only one “active active” tutorial. This tutorial reveals itself after a certain timeout and it will show its helper(s) after another. However I must stress while this one “active active” tutorial will run and play its animation and helpers, all other can still be completed.
And because these are tutorials, we are happy if you meet one of their completion requirements
If you choose to take a closer look at the tutorial driver, you will notice the heavy use of LINQed statements (ok that one fell flat xD) but I don’t consider these drivers to be super performance critical as they have a lot of early returns, operate on little data, and I also didn’t find the drivers on my profiler. Because we did have a performance issue but it was caused by our outline plugin because it did something very stupid causing … sub-optimal run-time behavior. Something like O(nnn)… Changing that, was my most impactful oneliner ever. Here again very familiar code just changed so it returns true when one requirement is met.
This code along with most of both drivers should should have been unified but these changes happend shortly before our release so that’s the lame excuse why the copied code is still copied-ish.
The tutorials have the aforementioned helpers. We wanted to provide contextual help and needed to delegate the specifics because our helpers are very different.
True, these are a bit on the nose but after a fair share of games not working due to information starvation and playtests indicating sys:logic was not working either because concepts were not understood (and we already put a lot of effort into information presentation of our games state), we figured it was time for drastic solutions.
This should cover most of our progression system. The interesting bits at least. The related code can be found in the progression system folder.
Tile System (Execution Graph)
More interesting than the progression however is the tile system. It is what essentially drives out game logic. Everything else is related to player-game-communication. But this very system is very distinct and the core. It gets context through the embedding into our world that is required to ease access to this system. However let’s get started with the tile itself. I am going to get into details as we move on.
I should have added a trigger warning… HEAVY USE OF OOP FEATURES. We have a deep inheritance structure with many virtual functions and some abstract classes.
The tile is a
MonoBehaviour because we use Unity and these are components that live on
GameObjects. Each tile knows its
Cell. However it is important that the value is invalid as long as they are not added to the
Grid. The default value of a
(0, 0) and caused quite a mess with our pathfinding and … “interesting” recursions when paths actually crossed
(0, 0) that led to a stack overflow. Lovely. Also because this value is serialised, setting a new default value didn’t help at all. But we will come to the second part of the fix later.
Grid is a reference to the owning, well, grid. We operate quite often on the grid itself. Thankfully I chose to do dependency injection here instead of making the grid a singleton. A singleton would have worked because in earlier version of the game we had only one grid. But since I didn’t, reworking the game from a single grid to multiple grids owned by an entity took only two days plus a few days to stabilise the game again.
Inactive property (yes, it is not a C# property but it is a property of the tile in terms of “when set it behaves differently”) is used by preplaced tiles to register them but render a question mark instead. The actual behavioural implementation is in
Module and I don’t know why I put it here. My bad.
Ok the next parts are quick.
OnAddTile() are callbacks that are triggered when a tile was added to a grid or is about to be removed from it.
GetNeighbours<T>() had once the implementation that the grid now has and as it was used I just put a redirect there. We needed the implementation in other parts where we didn’t have a tile object and just a cell instead.
Remove() was born out of lazyness however in my defence: Without it, someone would have simply used
Destroy and introduced an invalid state in the grid. It is also not straight forward that you need to remove the tile on the grid itself. I thought about using the
OnDestroy() message but decided against as most removals are actually triggered by outside code. It is a rare case that tiles delete themselves. And we cannot have both as that would trigger two removals which we consider a bug.
All our tiles live in a grid structure called
TileMap. It maps world space coordinates to grid space coordinates and keeps track of all tiles. It also comes with a good amount of utility functions that we build upon. It started as this data only structure but progressed to also generate the visible mesh and partake in the progression as well. Still we try to decouple it from the execution graph and progression systems through the usage of events.
Because we want to use types when possible and need to know which prefabs to spawn when we want to call something like
GridMap.AddTile<Buffer>(new Vector2Int(2, 3)), we have a tile database called
TileDB. It consists of a handful of settings for each module we add and is heavily used throughout our game.
For the descriptions we chose a static naming system to minimise the available options a keep consistency. In general, the usage of the Unity Localisation package helped us quite a bit even if we had only one language. Because we were able to treat these strings as assets. The proper structure gave us a speed advantage as we developed the game in both German and English at the same time. This was due to us release it on itch.io to a multi-lingual audiance and being a German research project attending German speaking conferences.
Now that we got the basics of the tile system covered, let’s move up a bit to the next class inheriting from tile:
See, there was a rule, especially in the beginning, to write doc comments as we knew the project would be travel through various hands with most knowledge getting lost in the process. And this was even more true for fundamental classes and methods.
Modules are the essential base class of our system. A module is an interactable thing that is meant to partake in the micro layer graph puzzle game play. That was on purpose because we considered adding obstacles to the grid and that would have worked perfectly fine with this approach as they could have inherited from
Tile, block these tiles and do stuff without interacting with the rest.
Modules also come with ports and are connected to other modules and connections. This got a bit messy admittedly. The reason being our connections and pathfinding, and because we wanted to exclude some modules from the execution graph. More on that later. Modules also must have UI. The rest is used for feedback and our colour pallete system (Grids can have a defined colour pallete and the modules need to match them obviously).
Here are some excerpts of functions that are implemented for further usage. Please, please ignore the
if (this is Connection)s. I know this is an architectual problem based on the very fact that
Module contains the
Connections among other things and not
ExecutableModule. However, as I am just showing this system here isolated, I cannot show you how widespread our use of
Module in the codebase is. It should have been done but life happend and we fixed it dirty. Then our time was over :/ It is not like that this a super hard refactoring to be made and one that should have been done. However these kind of fixes tend to get in at the least convenient times and quite frankly, our base classes were not touched that often, so forgot about it and every time me saw it, something else was way more important.
Our messengers are back! They notify the tiles around them about their arrival and departure. Also on removal of a module, we destroy all connections to that module as connections cannot live alone.
Start are both needed in child classes so these are marked as virtual.
SetModuleOutline has the third parameter because of a child override that needs it. Just some basic stuff to ensure UI is set up, we find our modules in the hierachy, and our ports are spawned.
That is the last part of the interface. Note how you should not believe everything that is doc comments. Sigh. Sadly, such wrongness in one place biting you once, leaves you with the feeling that the documentation may not be in a good shape overall. That is a huge problem, especially since we wrote these comments to fasten future work. We provide a basic UI showing method that can and has to be customised. It offers dismantling of tiles if they have a button (They should not have the button, if you cannot dismantle them). You then override this method to initialise your UI state when you have more, like sliders, buttons, or combo boxes.