Developer postOriginal post (opens in a new tab)

Diary Page #12: Systemic Architecture (Part 2)

Dear Scholars,
Continuing where I left off in the first part, I would like to show you the building blocks of my new architecture and how it compares to the typical ways of working in the Unity engine.

Inspiration

The idea for how the code architecture in Selenwald is going to look didn't come out of nowhere. The biggest influences were:

  • my past experience with Entity Component System (ECS) style framework (Entitas, to be specific) which I had previously worked with on a different project,
  • Brian's Bucklew's talk on the systemic and data driven architecture used in Sproggiwood and Caves of Qud,
  • my own journey of gradually making Selenwald more and more data-driven (mostly through various ways of utilising Scriptable Objects, heavily influenced by Ryan Hipple's talk on this subject) throughout the recent years.

    Not ECS

    Whether you're familiar with ECS frameworks or not, I should make it clear that my architecture is not an ECS despite me using the terms "entity" and "system". They are just convenient names that I stuck with. One of the key distinctions is that my systems are a combination of systems and components known from ECS - they contain logic but also data. Thus, every entity that uses a specific system has its own instance of it. Also, the Entity class and System-derived classes are MonoBehaviour scripts attached to game objects which is still "the Unity way" adding logic to objects. This comes with some overhead and I have plans to make Systems regular classes instead of MonoBehaviours to make the game loop a little bit faster. However, it's not a high priority and it can easily be done in very late stages of development. That being said, I will never fully utilise the performance benefits that actual ECS frameworks are known for because—like I said—creating an ECS wasn't my goal here. I just borrowed some ideas from it.

    Entity

    Every object in the game is built of two things - the Entity class instance and a stack of ordered systems. The main purpose of the Entity class is to gather special events which are how systems communicate with each other in a decoupled way (without knowing about each other). An event might be something like having a collision with another entity, receiving damage, or triggering the action of picking an item up from the ground. What's important is that these events are actually just data containers with unique types.

    The System Stack

    Now what actually happens during a frame? First take a look at this diagram showing the system stack on a timeline. Under the hood, each system type has an explicit order assigned. Bear in mind that the entities you can see here actually have much more systems but I skipped them to shorten and simplify the example.

    When a frame starts, the Entity class on each object in the game gets rid of the events from the previous frame and prepares events that were deliberately triggered by some systems in the previous frame to fire on the next frame (so the frame we're currently looking at).

    Then, the system with the lowest execution order executes on all entities that have it. No events have been raised at this point yet (except for some exceptions like the deliberately deferred events I mentioned before) so these early systems usually raise their own events and process their internal data instead of reacting to events sent by other systems. An example of such a system is the Player Controls System. It contains all the logic necessary for processing input. That logic includes queuing the commands so that you can—for example—press the attack button before while your character is still performing a dodge move so that they attack as soon as the unskippable part of the dodge animation finishes.

    All these possible input commands end up triggering these special events I've already mentioned. If we take the Dodge Event as an example, it contains an Action Move (my wrapper for a character animation) and a direction. Now the fun part is that when the Player Controls System sends out this event, the Action Move is empty because this system is not responsible for deciding what exactly should happen. It merely initiates the dodge but how it happens and whether it even happens depends on what other systems does the entity have. Another important behaviour to mention is that events only reach systems that come after the one that sent them, unless the event is set to deliberately fire on the next frame and go through the entire stack.

    Now, let's proceed down the stack - if the entity owns the Dodge System, that means the entity knows a dodge move and has it stored in the Dodge System's settings. So when the Dodge Event arrives, the Dodge System captures the event for a moment and injects its animation in this Action Move slot I mentioned.

    Then the event continues to be received by other systems along the stack. Let's say this entity is a character that earned a special trait that changes the dodge animation to some fancy acrobatic move. How would it be done? That trait would be a whole new system - let's call it the Acrobatic Trait System. That system would come after the Dodge System, also capture the Dodge Event and override the Action Move that was originally set by the Dodge System or do nothing if the Action Move has not previously been set - after all we want this trait to upgrade the dodge move but not do anything if the character can't dodge in the first place.

    In summary, when the entity changes it's not actually the systems permanently changing their settings. The old dodge animation is still there, stored in the Dodge System which still tries to apply it when the entity wants to dodge. The way entities change and evolve is by having systems added or removed on the fly. An animation override was added on top and the Dodge System doesn't have any clue that something is changing the animation later down the line.

    The final destination of the Dodge Event is the Animation System where events carrying animations get processed. The event recognizes that the Action Move in this event has been successfully populated and based on the properties of this animation executes it or not (e.g. some animations such as idles may have lower priority and can't interrupt others, while staggers should interrupt other actions).

    Something worth pointing out is that the Player Controls Systems is not the only system capable of sending out the Dodge Event (and the like). The AI System also does it. However, the AI needs to be processed late in the stack so the events raised by it are triggered in a delayed way - they wait until the next frame and then go through the entire system stack.

    Modularity

    What I just described is an architecture in which various parts of the games logic are decoupled. In traditional Unity programming patterns (or the lack thereof) it's natural for various components to reference each other directly, use and modify each others' data, call each others' methods, etc. This is fine for games with narrow possibility spaces. Because there are not that many interactions between systems, you can just explicitly code all the required behaviours. However, things get tricky if you're making a game that's very sandboxy in nature and the possibilities are virtually endless due to how various systems can interact with each other. That's when decoupling them becomes a solid approach. This means making them less aware of each other and making them only process some elementary, intermediate data without the care of where that data came from and where it goes. It comes with a drawback - it may make the code a bit harder to read and debug because you don't immediately see the chain reaction that has led to a problem. However, an approach like this makes code more resistant to game-breaking bugs and easier to expand because adding new systems usually doesn't require you to rewire the entire logic. A properly created system should work with existing systems out of the box. Now, while there is a smaller risk of problematic errors, the risk of having goofy bugs increases. Those are bugs that don't necessarily break the game but introduce weird behaviours and exploits due to interactions between systems that weren't even predicted by the developer. However, that's precisely the charm of sandbox games, isn't it?

    Consequences

    If you've read the above, you may already have some ideas on how this enormous possibility space can be used. Also, as you likely noticed, the only thing separating the player character from an NPC is the Player Controls System. That means that a mind control spell would merely need to remove this system from the player's character and add it to a different entity (human or not) and it would just work. In non modular frameworks the player character would likely be coded and constructed differently from other creatures, making it challenging to introduce a mechanic like this. A lot of mechanics that would be quite challenging to introduce normally, become trivial with a modular architecture because everything follows the same rules.

    Unfortunately this is a double-edged sword. There are also things that took me very little time when I originally made them in the old codebase but took an unexpectedly long amount of time now. Such example is the inventory system. That's because previously items such as weapons and potions were a completely separate kind of an object as compared to creatures. It was very limiting but also relatively simple to work with. Now that items are just entities capable of having any systems, this part of the game got quite complicated. Even such a basic thing as getting the item's icon so that it can be displayed in the UI requires first sending a proper event on the character, having that event received by item-carrying systems such as Hands System or Outfit System, propagated further to the contained entities (the items held in hands, pockets, etc.), and finally received by the UI systems that requested the icons.

    Such architecture basically makes the time required for adding new systems less exponential, which is good for games that scale, but adds some flat overhead to everything so that some seemingly simple pieces of logic still need to follow the rules and go through the system stack pipeline.

    Constructing entities

    But how are entities being defined in the first place? How are the complex hierarchies of 3D animated game objects constructed? That's another major distinction between my architecture and the traditional Unity workflow as it flips the idea of prefabs upside down. And this is where the time efficiency gain is the most dramatic. That's what I'll tell you about in the next entry.

    As usual, a small reminder that if you want to support the development process, you can do it at Ko-fi.

    Thank you for reading,
    Wiktor