Games change a lot during development, but code isn't always easy to change. Games also need a lot of testing, but code isn't always easy to test. Teams can sometimes change during development, and code isn't always easy to understand, especially for newcomers. With these 3 issues in mind, I've developed this Gameplay Framework. It's been applied successfully in a commercial project, and the team has adapted to it quite well. Since Unity doesn't have a standard gameplay framework, I hope this helps other games get built more easily and responsibly.
- Install the core dependency first using the link:
https://github.com/GiovanniZambiasi/middlemast-core.git
- Then, install this package:
https://github.com/GiovanniZambiasi/gameplay-framework-unity.git
This framework defines custom callbacks. This is done to avoid the inconsistencies and performance concerns of Unity's built-in life-cycle callbacks*. Unity callbacks should be avoided as often as possible.
* Unity's life-cycle callbacks are: Awake
, Start
, Update
/LateUpdate
/FixedUpdate
, and OnDestroy
Keyword | Corresponding Unity Callback |
---|---|
Setup | Start/Awake |
Dispose | OnDestroy |
Tick | Update |
Having manual control over the order of the setup/update/dispose of your classes can be very beneficial. This avoids race-conditions between MonoBehaviours
(ever had a bug where some MonoBehaviour
's Start
method got called before another's, and the former depended on the latter to initialize itself?). It also enables any developer in the project to understand exactly in what order things are happenning, without having to worry about the Script Execution Order settings hidden away in a menu. Defining custom Tick(float deltaTime)
methods can also be extremely useful when writing unit tests.
Severity | Description |
---|---|
π₯ | Severe. Must always be followed |
π¨ | Encouraged. Should mostly be followed, but can have exceptions |
π© | Suggestion. May not apply to a specific project |
Rule | Severity |
---|---|
Keep the root namespace of an assembly the same as the assembly name | π₯ |
The namespaces in your scripts must match your folder structure, taking the root namespace of the assembly into consideration |
π₯ |
All of your project's scripts must live under the same root folder | π¨ |
All assemblies must live inside their own folder. And that folder must live in the root of your scripts folder. You cannot have assemblies inside other assemblies* | π¨ |
* Here's an example of an ideal assembly setup:
_Project/
'-Scripts/
'-WizardsAndGoblins/
'-WizardsAndGoblins.asmdef
'-WizardsAndGoblins.Tests/
'-WizardsAndGoblins.Tests.asmdef
'-WizardsAndGoblins.Editor/
'-WizardsAndGoblins.Editor.asmdef
'-WizardsAndGoblins.Editor.Tests/
'-WizardsAndGoblins.Editor.Tests.asmdef
'-WizardsAndGoblins.Gameplay/
'-WizardsAndGoblins.Gameplay.asmdef
'-WizardsAndGoblins.Gameplay.Test/
'-WizardsAndGoblins.Gameplay.Test.asmdef
'-WizardsAndGoblins.Gameplay.Editor/
'-WizardsAndGoblins.Gameplay.Editor.asmdef
// etc..
A System
is an entry point with the engine. It's meant to be an autonomous class that initializes/disposes/updates itself through Callbacks. Systems
can be big or small, and you can define as many as you like. While designing Systems
, try to find isolated chunks of behaviour in your project. This will help keep related things together, and will naturally avoid making a mess with your scripts and namespaces
. Systems
must be as indepentent from one another as possible. For a Counter-Strike style game, you could define the gameplay portion as a GameplaySystem
, and the Menus and matchmaking as a MenuSystem
, for example.
Rule | Severity |
---|---|
Each System must be inside its own namespace (optionally named System )This helps enforce the Namespaces Rule |
π₯ |
Each System must be in its own GameObject |
π₯ |
Communication between Systems must always be abstracted (a System shouldn't have any knowledge about another System ) |
π₯ |
Each System should be in a separate Assembly |
π¨ |
Keep all Systems at the root of a Scene |
π¨ |
Make all your Systems internal * |
π¨ |
Add the System suffix to all Systems (and the GameObjects ' names should match the type names) |
π© |
* For unit testing purposes, you can use the InternalsVisibleTo
attribute to give your test assemblies access to your System
A System
can encapsulate it's behaviour using Managers
. It can have any number of them, and their lifetimes will be managed by their owning System
. They have generic callbacks for Setup
, Dispose
and Tick(float deltaTime)
, but can implement any overloads your project requires. Managers
can be added to a system using the hierarchy of a Unity scene. Any GameObject
with a Manager
component that's a child of a System
will be registered:
During Setup
, the System
will initialize each Manager
, in the exact order of the hierarchy. In the example above, EarlyManager
will be setup first, and LateManager
will be setup last. This is also the order in which the Tick
funcitions will get called.
Dispose
is a bit different because it happens in reverse. In the example above, LateManager
will be the first disposed, and EarlyManager
will be last. In most cases, this is the desired behaviour.
A Manager
is the basic building-block of a System
. The main difference between Systems
and Managers
is that Managers
are not autonomous. This means that their life-cycles must be managed entirely by their owning System
. In other words, they must not implement any life-cycle related Unity callbacks. Managers
already implement default life-cycle callbacks, but more callbacks can be defined to match your game's needs. In a turn-based game, you could define TurnEnd
and TurnStart
callbacks, passed along to your Managers
by some GameplaySystem
, for example.
Rule | Severity |
---|---|
Communication between Managers must always be abstracted (a Manager shouldn't have any knowledge about another Manager ) |
π₯ |
Managers must define their own namespaces * (Optionally named Manager )This helps enforce the Namespaces Rule |
π₯ |
Managers must not implement any life-cycle related Unity callbacks |
π₯ |
Each Manager must live in its own GameObject , and must be a first-level-child of a System |
π₯ |
Make all your Managers internal ** |
π¨ |
Add the Manager suffix to all Managers (and their GameObject 's names should match their type names) |
π© |
* An example will be shown in the chapter below | |
** For unit testing purposes, you can use the InternalsVisibleTo attribute to give your test assemblies access to your Manager |
In the diagram above, Market
is the root namespace of the assembly. Each Manager
defines its own child namespace (such as Basket
or Clients
), and their corresponding Manager
namespaces. Note that Managers
should never depend upon one another, or depend upon the internals of anonther manager's namespace. This rule becomes clearer in the Abstraction chapter.
Managers
are likely to have varying dependencies that must be fulfilled during their Setup
. For example, you could have a StoreManager
which needs a reference to a StoreData
ScriptableObject
. In this case, the default Setup
callback may not be so useful, and custom overloads should be defined. It's encouraged to use the same naming for your default callback overloads. For example, the StoreManager
could have a Setup(StoreData storeData)
overload for the default Setup
callback. The System
could then override the SetupManagers
method, and call the overloaded version of Setup
:
public class MenuSystem : System
{
[SerializeField] private StoreData _storeData;
protected override void SetupManagers()
{
base.SetupManagers();
StoreManager storeManager = GetManager<StoreManager>();
storeManager.Setup(_storeData);
}
}
Entities
are the individual objects that make up your gameplay. A Zombie
, the Player
or a WoodenBox
could all be considered entities. Most games organize their entities into Prefabs
, and these objects can be spawned at runtime, or be dragged into a scene beforehand. They can have various Components
, such as a Rigidbody
, HealthComponent
or an Animator
. However, every Entity
must be defined as it's own MonoBehaviour
:
public class Wizard : Entity
{
// Magic...
}
Rule | Severity |
---|---|
Entities must not implement any life-cycle related Unity callbacks |
π₯ |
Each Entity must live in it's own GameObject |
π₯ |
Each Entity must have a corresponding Manager * |
π¨ |
Make all your Entities internal ** |
π¨ |
* There could be some cases where a System
is simple enough that it can manage entities by itself
**For unit testing purposes, you can use the InternalsVisibleTo
attribute to give your test assemblies access to your Entity
*
In game development, most modern engines encourage composition, and for good reason. However, in some cases, we need a "central point" for our game's entities to orchestrate complex interactions between Components
. For example, say you had a HealthComponent
and a MovementComponent
. You want to reduce your Player
's movement speed when their health drops below 50%. To achieve this, you need some sort of communication between those two components. That's where the Player
Entity
comes in:
public class Player : Entity
{
private HealthComponent _health;
private MovementComponent _movement;
private float _slowSpeed;
public override void Setup()
{
base.Setup();
_health.OnDamageTaken += HandleDamageTaken;
_slowSpeed = _movement.Speed * .66f;
}
private void HandleDamageTaken()
{
if (_health.HealthRatio <= .5f)
{
_movement.Speed = _slowSpeed;
}
}
}
It's a central point of communication between the two. That way, your HealthComponent
only needs to worry about damage and health, and your MovementComponent
only needs to worry about translation and physics.
With good abstraction, you could achieve this "slow-down-when-almost-dying" behaviour without the need for a Player
. If your code is modular enough that it doesn't need the Entity
to be defined in code, then don't define it. Entities
will be most useful for objects that have a high number of Components
, all performing complex interactions with one another.
Later in this document, there's an example of how to implement a Wizard
that can cast Fireballs
at Goblins
following all the rules of this framework.
Components are tiny, autonomous, encapsulated slices of behaviour that live inside your Systems
, Managers
or Entities
. In fact, any MonoBehaviour
is considered a component by the engine. This doesn't mean that all MonoBehaviours
you create are components from a conceptual standpoint. For example, Systems
, Managers
or Entities
are not components. The only reason we make them MonoBehaviours
is because Unity doesn't allow us to inherit from GameObject
.
Most components are modular by nature. Some are so modular that they can just be added into an object and they'll work, without any need for external help. This is a great resource, and it should be leveraged extensively. Since we want to preserve the modular and autonomous nature of most components, they can implement Unity's life-cycle callbacks.
Rule | Severity |
---|---|
Add the Component suffix to all your components |
π© |
To create a component, simply make a MonoBehaviour
how you usually would. A great example of a modular component is the Rigidbody
. You can just add it to any object and they'll fall downwards (or upwards, depending on your gravity settings). If you want your components to be managed by their owning object, you can do so. It's up to the developer to decide whether the component is in fact autonomous or not:
For example, take a BillboardComponent
. It can simply point a transform towards the main camera on Update
. This is a component that can be autonomous:
namespace DogsAndBillboards
{
public class BillboardComponent : MonoBehaviour
{
private void LateUpdate()
{
Camera mainCamera = Camera.main;
Vector3 lookDirection = (mainCamera.transform.position - transform.position).normalized;
transform.rotation = Quaternion.LookRotation(lookDirection);
}
}
}
This component doesn't need to be managed. It can resolve its dependencies by itself, and doesn't communicate with any other MonoBehaviours
. This is not always the case:
Say we have a Dog
Entity
with a DogAnimations
component. The Dog
has a BreedData
ScriptableObject
that contains important information. The DogAnimation
component needs to be setup by it's owning Dog
, so it can initialize itself with the correct BreedData
:
namespace DogsAndBillboards.Dogs
{
public class BreedData : ScriptableObject
{
// Information about size, sound and animations...
}
public class DogAnimations : MonoBehaviour
{
public void Setup(BreedData data)
{
// Initializes itself based on the breed
}
}
public class Dog : Entity
{
private DogAnimations _animations;
public void Setup(BreedData breed)
{
_animations = GetComponent<DogAnimations>();
_animations.Setup(breed);
}
}
}
In this case DogAnimations
is still a component, but it's managed by an Entity
.
Abstraction is very important. It's an integral part of writing clean code, and it's all about defining how much types
should know about each other. The less they know, the more decoupled your codebase gets. Decoupling is very useful, as it enables developers to write clean, encapsulated code that is easily changed, tested and understood. Thus, in most cases, abstraction should be encouraged. However, as important as it may be, abstraction can be quite complicated to apply consistently.
The rules from the previous chapters were crafted to answer this question. This framework defines namespaces
as layers of abstraction. The idea is that each namespace
defines a system boundary. Whenever you need to have communication between two separate namespaces
, abstraction should be applied. This boils down to a simple rule, that can be easily followed:
- A
Type
can only know about anotherType
if they're in the samenamespace
π₯- This applies hierarchically, so nested
namespaces
can know aboutTypes
in their parentnamespace
- This doesn't mean that you shouldn't use abstraction within a particular
namespace
. It can, and should, be considered
- This applies hierarchically, so nested
Combining this with the rules from the other chapters will help you apply abstraction to your codebase very consistently. Here's an example:
In the image above, the dotted lines represent a map of who knows whom. Notice how all the lines coming from the nested namespace (TheAncientScrolls.Dragons
) have arrows, symbolizing that they only go one way. In the parent namespace (TheAncientScrolls
), there are 3 types: AttributeSet
, IInteractable
, ICatchFire
. They can all know about each other, as they're in the same namespace
. However, they can't know about any types inside TheAncientScrolls.Dragons
. On the other hand, TheAncientScrolls.Dragons
is still a part of TheAncientScrolls
. Therefore, types inside TheAncientScrolls.Dragons
can know about types in TheAncientScrolls
.
The root namespace
of an assembly should be reserved mostly to interfaces, shared data objects, extension methods and some components.
After applying this framework extensively I've found that types which are root-namespace-worthy often fit a few of the below criteria:
- Types that are depended upon by many other types
- Types that donβt have dependencies to other types inside special namespaces
- Types that are likely not to change too much
- Abstract interfaces used for communication between types in separate namespaces
- Data-only types
- Global utility classes/extension methods
Thereβs nothing wrong with moving existing types into the root namespace later on, after you've found yourself having to create too many interfaces for it.
The namespaces
rule will be most useful for your runtime scripts, where most of the buisness logic of your application will go. However, exceptions can be made for Editor
and Test
assemblies, since those namespaces are technically different than their respective types. For example, if you have a Wizard : Entity
in a WizardsAndGoblins.Wizards
namespace
, an editor for it would likely be called WizardEditor
inside a WizardsAndGoblins.Editor.Wizards
namespace
. In this case WizardEditor
needs to know about the type in WizardsAndGoblins.Wizards
.
Another exception to the rule are Systems
. Since they will be responsible for all Managers
and their Entities
, forbidding access to their types could become counter-productive. If you deem it neccessary, you can apply abstraction between the System
and its Managers
. This could be useful if you want to have different Managers
on different levels of you game, but still maintain the same System
. For example, you could have an interface IObjectiveManager
that has two implementations: CaptureTheFlagManager
and TeamDeathmatchManager
, one for each game mode. The System
could use that interface to communicate with them, preserving abstraction between the two.
There are many ways of achieving abstraction, but here are some examples involving Managers
and a System
:
-
Using C# events:
-
Using interfaces:
-
Using some sort of event bus, to achieve complete abstraction between an event's sender and listener. Here's a good example of how to implement one
Our goal in this chapter is to make a Wizard
that can cast a Fireball
at a Goblin
. This should provide a good understanding of how the framework is meant to be used.
This example requires 3 Entities
with corresponding Managers
:
Consider each folder in the example a C# namespace
Now, the Wizard
needs to be able to cast a Fireball
, but they're in separate namespaces. Simply including the using WizardsAndGoblins.Spells
directive in any of the Wizard
's scripts would be a violation of the Namespaces Rule. This is where abstraction comes into play.
We need to define a communication layer between Wizards
and their Spells
. For that, we will declare an interface:
namespace WizardsAndGoblins // Notice how the interface is declared in the root namespace of the assembly
{
public interface ISpell
{
void Activate();
}
}
Then, Fireball
must implement this interface:
namespace WizardsAndGoblins.Spells
{
internal class Fireball : Entity, ISpell
{
private Rigidbody _rigidbody;
public override void Setup()
{
base.Setup();
_rigidbody = GetComponent<Rigidbody>();
}
public void Activate()
{
_rigidbody.AddRelativeForce(transform.forward * 10f, ForceMode.Impulse);
}
}
}
For now, the Activate
method is all the Wizard
needs to be able to communicate with its Fireball
.
Now we need to enable the Wizard
to create the Fireball
Entity
. How do we do that, considering Fireball
is still inaccessible to Wizard
?
For that we need another interface:
namespace WizardsAndGoblins
{
public interface ISpellFactory
{
ISpell CreateSpell(Vector3 position, Vector3 direction);
}
}
And we can make the SpellManager
implement it:
namespace WizardsAndGoblins.Spells
{
internal class SpellManager : Manager, ISpellFactory
{
[SerializeField] private GameObject _spellPrefab;
private List<Entity> _spells = new List<Entity>();
public override void Tick(float deltaTime)
{
base.Tick(deltaTime);
for (int i = 0; i < _spells.Count; i++)
{
Entity spell = _spells[i];
spell.Tick(deltaTime);
}
}
public ISpell CreateSpell(Vector3 position, Vector3 direction)
{
if (!_spellPrefab.TryGetComponent(out ISpell spell))
{
Debug.LogError($"Prefab '{_spellPrefab.name}' is not a spell!");
return null;
}
spell = Instantiate(_spellPrefab, position, Quaternion.LookRotation(direction)).GetComponent<ISpell>();
if (spell is Entity entity)
{
_spells.Add(entity);
}
return spell;
}
}
}
I have also added some code that makes the SpellManager
register spells, and update them using Tick
.
I'm simplifying the dependencies here to keep the UML tidy. In this case, Wizard
also depends upon ISpell
With ISpellFactory
, we managed to create an abstract way to spawn instances of Fireball
. Now, we can define the Wizard
's CastSpell
method:
namespace WizardsAndGoblins.Wizards
{
internal class Wizard : Entity
{
private ISpellFactory _spellFactory;
public void Setup(ISpellFactory spellFactory)
{
_spellFactory = spellFactory;
}
public void CastSpell(Vector3 direction)
{
ISpell spell = _spellFactory.CreateSpell(transform.position, direction);
spell.Activate();
}
}
}
Notice how the Wizard
's dependency to ISpellFactory
is fulfilled in the Setup
method overload. This is how the WizardManager
does that:
namespace WizardsAndGoblins.Wizards
{
internal class WizardManager : Manager
{
[SerializeField] private Wizard _wizardPrefab;
private ISpellFactory _spellFactory;
private Wizard _wizard;
public void Setup(ISpellFactory spellFactory)
{
_spellFactory = spellFactory;
CreateWizard();
}
public override void Tick(float deltaTime)
{
base.Tick(deltaTime);
_wizard.Tick(deltaTime);
}
private void CreateWizard()
{
_wizard = Instantiate(_wizardPrefab);
_wizard.Setup(_spellFactory);
}
}
}
And the WizardManager
's dependencies are fulfilled by the GameplaySystem
:
namespace WizardsAndGoblins
{
internal class GameplaySystem : System
{
protected override void SetupManagers()
{
base.SetupManagers();
SpellManager spellManager = GetManager<SpellManager>();
WizardManager wizardManager = GetManager<WizardManager>();
wizardManager.Setup(spellManager);
}
}
}
And voila! Just like magic, the Wizard
is able to cast a Fireball
, without any code coupling. Now, why go through all the trouble?
A great benefit of all this infrastructure is this: Notice how the Wizard
Entity
has no idea of what the spell is, or what it does. If we wanted to completely change what spell the Wizard
casts, we could easily do so, without even having to open the Wizard
's script for editing. All that we would need to do is define another ISpell
Entity
with a different behaviour. A Heal
spell, for example, could look like this:
namespace WizardsAndGoblins.Spells
{
internal class Heal : Entity, ISpell
{
public void Activate()
{
// Finds nearby healable objects using collision, and add some value to their health
}
}
}
Also, notice how neither the Wizard
nor the WizardManager
know how a spell is created, managed or destroyed. This gives us the flexibility of using whichever infrastructure we need for ISpell
. For example, if we had to use a pre built spell plugin from the asset store, we could define ISpell
and ISpellFactory
implementations to communicate with it, and the rest of our codebase wouldn't even know.
Another great benefit of using interfaces this way is that we can create fake (hollow, humble, mock etc..) implementations of our dependencies to use in automated tests. For example, If we're writing a unit test for the Wizard
class, we could create a HumbleSpell : ISpell
class that does exactly what the test needs it to do, without the headache of having to create an actual entity.
That is β¨the power of abstraction β¨
// Tbd..