SatiRogue: ECS experiments in Godot 3.x C# with RelEcs

good work Oct 22, 2022

A few months ago, I stumbled upon a C# package called RelEcs, along with its companion RelEcsGodot, via the ChickenSoft discord server, which has an excellent, if small, community of Godot C# developers talking shop in a less chaotic fashion than the main Godot discord.

RelEcs is a pure C#, engine-agnostic ECS library inspired by Bevy's rust ECS API. Bevy is an engine that takes ECS and runs with it; you're expected to use the ECS pattern for pretty much everything in a Bevy game, and the entire framework is tightly integrated with the ECS API.

Below is a quick explanation of ECS vs traditional OO design. If you're comfortable with ECS concepts already, click here to skip it.


Object-oriented growing pains

📚
ECS stands for "Entity Component System", and is a software architecture often used in modern game development that (slightly) eschews traditional Object-Oriented design, while still making use of the OO paradigm.

In a naive OO approach, you could begin designing an action-RPG game by creating a hierarchy of classes, each defining their own behaviour and inheriting their parents'.
Using basic OO design principles, you might start to implement in-game objects and their behaviours using an inheritance tree hierarchy like this.

So, what's wrong with the above? Well, nothing, exactly. Many games use strict OO design resembling the above and work just fine. There are, however, several issues you may run into when designing a game's code like this.

  • Which behaviours are specific to Player, Enemy or Npc, and which are inherited from the base Character class?
    • Code written in the child classes can be often generalised to the Character class to prevent repetition.
    • The Character class can quickly grow large and obscure as it provides abstract or partial implementations for all of its child types.
    • With the partial behaviour implemented in Character, you might extend it in Enemy and Npc, but override it completely in Player. This can lead to bugs where an improvement or fix to the base Character code may unintentionally not be inherited by the Player or other specific classes.
  • Inheriting from Weapon, we have Sword and Bow. Sword is further specialised into ElectricSword and FireSword.
    • What happens when we want a sword that has both Electric and Fire effects?
      • Do we implement a new Sword-inheriting class, ElectricFireSword and re-implement the elemental behaviours?
      • Or do we create a new base class, ElementalSword, where most of the behaviour really lives, for Electric-, Fire- and ElectricFireSword to inherit from?
    • Sword implements melee damage, and Bow implements ranged damage. But what happens when we want an ElementalSword that can do ranged damage like a Bow as well as melee damage like a Sword?

These are some basic things to think about, that, to be fair, do have workable solutions in traditional OO. Over the course of development, as the game expands and complex features and mechanics are added, the inheritance tree can become very complex.

As a solo dev or small team, it can be difficult find the actual, complete implementation of a behaviour you want to change, fix, or extend. The increasingly complex hierarchy forces you to implement abstract design patterns with partial implementations and partial overrides spread across your class hierarchy to achieve your goals.

The code for a seemingly discreet game mechanic, say, "weapons doing damage to characters", may end up split over several classes at different levels in potentially unrelated branches of the hierarchy, which can lead to bugs when a behaviour's implementation is only partially changed, due to the lack of visibility into relevant (but indirectly linked) places in the code, especially if a lot of indirection is used (as is typical in clean, DRY OO code).

One (good!) solution to these potential issues, which you'll have seen repeatedly if you spend much time chatting to other devs, is to emphasise composition over inheritance. Rather than maximising the use of the inheritance features of OO languages, aim to keep the class hierarchy as flat as possible, and have the objects in your hierarchy contain references to their specific behaviours and definitions instead of inherit them, storing the more specific details of what a particular object is, and what it behaves like, as properties of its otherwise generalised class.

Using composition, we can represent the same variety of objects using a much shallower tree, composing the definitions of objects using language features such as enums.

This looks much simpler already, doesn't it? There are now way fewer classes. However, the implementations of these game objects' behaviours may end up being more densely packed into these fewer classes, making big, complicated, generalised class files that have to account for many specific cases their compositions create.

Using OO principles, it seems like a sensible idea to decouple the behaviours of characters and items from the character and item objects themselves. There are many old-school OO patterns you can use to help solve this; I've always loved using the Command pattern in my games to abstract behaviours from the things that invoke those behaviours, for just one example.

In my mind, besides the traditional gang-of-four patterns, ECS is one of the ultimate conclusions of ideas around composing objects and decoupling behaviours, creating a clear, organised architecture that can help to keep the mental overhead of game programming more linear rather than exponential, more horizontal than vertical, as the codebase grows and more features are added.

Entities, Components, Systems

In ECS methodology, a game is typically composed of 3 distinct parts: Entities, Components and Systems. Despite what you might think, this doesn't exactly map onto the hierarchical class design seen above. You might think you would define a Character entity, a Sword entity and a Potion entity, for example; but in ECS, an Entity is much simpler than that.


#️⃣
An Entity is simply an object that contains a unique id. It doesn't define any behaviour, it doesn't have any distinguishing features except the unique id. It represents some distinct object or value in the game in its most minimal form.
🗄️
A Component acts primarily as a place to tag, store and manipulate data. For example, you could have an empty WeaponComponent that defines an entity as a weapon of some sort, a DamageTypeComponent that holds a { Melee, Ranged } enum array, and ElementalDamageComponent that holds a { Fire, Water, Electric } enum array.
➡️
Components can then be "attached" and "detached" from Entities at runtime; but the Entity doesn't really "own" the components; it's just a unique id with no properties, after all. Instead, the unique id is associated with a particular component. The components are stored in typed contiguous arrays, one for each component type.
🖥️
Systems are where the actual implementations of behaviours applicable to game objects is defined. A System has an invocation method, e.g. Run(), that may be invoked every frame, or every physics tick, or once on initialisation, etc. The System is related to once specific behaviour, and typically contains the complete implementation of that behaviour, decoupled from the actual objects themselves; it drives behaviours externally, creating a clean divide between game objects (entities, unique ids), definitions/data about those objects (components), and the behaviours that manipulate them (systems).

Systems, in most ECS designs, query for groups of Components, rather than Entities, which may be counter-intuitive coming from the traditional OO paradigm. As an example, a MeleeDamageSystem may ask the ECS system to return iterable sets of components that are all attached, as a group, to any entity in the game world. So, a `Query` to the ECS system asks for a set of Components it wants to operate on, rather than this or that type of entity or object.

For example, a Query might ask for a WeaponComponent and a DamageTypeComponent, where the DamageTypeComponent's enum array contains Melee.  The system will step through each returned matching set of (WeaponComponent, DamageTypeComponent), applying game logic, checking for - and applying - damage as appropriate.

Crucially, because Components are stored in these typed, contiguous arrays, it's both quick to look up and iterate over them, as the runtime/compiler merely needs to lookup the array location in memory and step the pointer forward one by one until the end, which is relatively cheap vs the fragmented stack/heap memory allocation of typical OO designs.

The key point to take from this design is that game objects are no longer self-defined and their behaviours are not specific to certain types or branches of a hierarchy; any Component  can be associated with any Entity, in any combination; if you decide that you want swords to shoot, you merely attach the set of components that indicate ranged damage, and the RangedDamageSystem will now pick it up and act on it - regardless of what type of object it's supposed to be.

RelEcs, RelEcsGodot... and Game States

As mentioned, RelEcs is an engine agnostic, platform independent C# implementation of the above principles. It's open source, MIT licensed, and has a clean, straightforward design that aims to provide a selection of convenience features while keeping the core API as hands-off and simple as possible. It's absolutely a library rather than a framework; it makes few assumptions about what you're building with it, and so it's up to you to manage your ECS implementation via its API.

As a small convenience for Godot engine users such as myself, RelEcsGodot supplies an extra Godot.cs file for RelEcs that implements a few classes, interfaces and extension methods, creating some convenient features if using RelEcs with Godot, mainly:

  • Linking spawned entities to a given Godot Node instance,
  • Despawning entities and calling QueueFree on their related Node
  • Storing the entity's unique Identity as a marshallable object in the built-in metadata dictionary of the Node, so it can be retrieved from within the Godot API via its GetMeta function (such as during collision detection with another arbitrary PhysicsBody Node)
  • Querying for Node types as if they were components.
    • Any Node type (i.e. built in Godot Nodes or your own types that extend Node or one of its subclasses) attached to an entity is treated as a Component by RelEcs
    • You can therefore query the ECS for, e.g., all KinematicBody nodes that are attached to an entity alongside ProjectileComponents
    • This allows you to seamlessly work with ECS Components and Godot Nodes together.

In the author's example project, explore, a GameStateController class is introduced. In my opinion, this is the missing piece of the puzzle when it comes to productive use of RelEcs. It contains a Stack of GameState objects, the newest of which is the "active" state, the states "underneath" paused and made inactive when a new state is pushed onto the Stack.

The GameStates themselves contain a set of SystemGroups: RelEcs objects that provide a convenient way to collect related Systems together and execute them in order. A GameState contains the following SystemGroups that are automatically executed by the GameStateController when the right conditions are met:

  • InitSystems that are executed when a GameState is first pushed onto the stack
  • UpdateSystems that are executed every frame
  • PauseSystems that are executed when a new state is pushed onto the stack, pausing this state
  • ResumeSystems that are executed when this state becomes the top of the stack again, after being paused
  • ExitSystems that are executed when this state is popped from the stack and removed

This simple set of SystemGroups allows for a powerful state management system that runs the relevant systems for you automatically once initialised. In my game, SatiRogue, I have, for example, a LoadingState, MenuState, MapGenState, and DungeonState that become active in roughly that order to control the flow of the game from boot, to play, to exit, allowing for the game to automatically initialise and cleanup after itself as the user navigates the depths of the dungeon (and inevitably perishes).

I'll be writing up another post detailing the specific implementation of SatiRogue and its use of RelEcs(Godot), with some Godot-specific tips and tricks for working in this way - Godot itself is not an ECS-based engine, in fact, it's one of the more traditionally OO engines out there, so using ECS for everything as I've done presents some unique challenges that, in my opinion, once grokked, makes for a much more manageable and navigable codebase for a long and complicated Godot project.

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.