Quantum Systems (game logic)
Introduction
Systems are the entry points for all gameplay logic in Quantum.
They are implemented as normal C# classes, although a few restrictions apply for a System to be compliant with the predict/rollback model:
- Have to be stateless (gameplay data will be passed as a parameter by Quantum's simulator to every game loop callback);
- Use C# pointers (so Systems must use the unsafe keyword);
- Implement and/or use only deterministic libraries and algorithms (we provide libraries for fixed point math, vector math, physics, random number generation, path finding, etc);
- Reside in the Quantum namespace;
Basic System
A basic System in Quantum is a C# class that inherits from "SystemBase".
The skeleton implementation requires at least the Update callback to be defined:
C#
namespace Quantum
{
public unsafe class MySystem : SystemBase
{
public override void Update(Frame f)
{
}
}
}
These are the callbacks that can be overridden in a System class:
- OnInit(Frame f): called only once, when the gameplay is being initialized (good place to create initial entities, set up game data, etc);
- Update(Frame f): used to advance the game state (game loop entry point);
- OnDisabled(Frame f) and OnEnabled(Frame f): called when a system is disabled/enabled by another system (these will be covered in the next chapter);
Notice that all available callbacks include the same parameter (Frame).
The Frame class is the container for all the transient and static game state data, including the generated API for entities, physics, and others (each will be covered separately).
The reason for this is that Systems must be stateless to comply with Quantum's predict/rollback model.
Quantum only guarantees determinism if all game state data is fully contained in the Frame class (generated from the DSL definitions).
It is valid to create read-only constants or private methods (that should receive all need data as parameters).
The following code snippet shows some basic examples of valid and not valid (violating the stateless requirement) in a System:
C#
namespace Quantum
{
public unsafe class MySystem : SystemBase
{
// this is ok
private const int _readOnlyData = 10;
// this is NOT ok (this data will not be rolled back, so it would lead to instant drifts between game clients during rollbacks)
private int _transientData = 10;
public override void Update(Frame f)
{
// ok to use a constant to compute something here
var temporaryData = _readOnlyData + 5;
// NOT ok to modify transient data that lives outside of the Frame object:
_transientData = 5;
}
}
}
System Setup
Concrete System classes must be injected into Quantum's simulator during gameplay initialization.
This is done via the file "SystemSetup.cs", included in the quantum.systems project:
C#
namespace Quantum
{
public static class SystemSetup
{
public static SystemBase[] CreateSystems(RuntimeConfig gameConfig, SimulationConfig simulationConfig)
{
return new SystemBase[]
{
// pre-defined core systems
new Core.PhysicsSystemPre(),
// user systems go here
new MySystem(),
// pre-defined core systems
new Core.AnimatorSystem(),
};
}
}
}
Notice that Quantum includes a few pre-built Systems (entry point for the physics engine updates and the deterministic animator manager).
To guarantee determinism, the order in which Systems are inserted will be the order in which all callbacks will be executed by the simulator on all clients.
So, to control the sequence in which your updates occur, just insert your custom systems in the desired order.
Activating and Deactivating Systems
All injected systems are active by default, but it is possible to control their status in runtime by calling these generic functions from any place in the simulation (they are available in the Frame object):
C#
public override void OnInit(Frame f)
{
// deactivates MySystem, so no updates (or signals) are called in it
f.SystemDisable<MySystem>();
// (re)activates MySystem
f.SystemEnable<MySystem>();
// possible to query if a System is currently enabled
var enabled = f.SystemIsEnabled<MySystem>();
}
Any System can deactivate (and re-activate) another System, so one common pattern is to have a main controller system that manages the active/inactive lifecycle of more specialized Systems using a simple state machine (one example is to have an in-game lobby first, with a countdown to gameplay, then normal gameplay, and finally a score state).
Entities Generated API
Systems are the entry points for the gameplay logic in a Quantum game, so the actually useful things revolve around entities lifecycles: create, update, destroy.
When an entity type is defined in the DSL files, all needed API calls to manage their instances lifecycle are generated automatically.
So, taking as an example the following "Character" entity based on the topics covered in the previous chapter (DSL):
C#
// this goes in a Quantum DSL file
entity Character[8]
{
use Transform2D;
use DynamicBody;
}
The following functions will be available in the Frame class:
C#
// finds the next free Character slot (from 8 pre-allocated ones) and returns a pointer to it.
Returns null if there are no free slots;
var c = f.CreateCharacter();
// entity refs (explained in the next section) are rollbackable and safe to store across frame both in the game state and from Unity
var reference = c->EntityRef;
// entity references can be used to retrive back the pointer from the Frame object (returns null if ref is obsolete/invalid)
var ca = f.GetCharacter(reference);
// optional check to avoid the null return:
var exists = f.CharacterExists(reference);
// to destroy an entity (also making all refs to it obsolete), one can use either the pointer or a ref.
// This frees up a Character entity slow as well:
f.DestroyCharacter(c);
f.DestroyCharacter(reference);
The EntityRef Type
Quantum's rollback model means we maintain a variable sized frame buffer, meaning several copies of the game state data (defined from the DSL) is kept in memory blocks in separate locations.
This means that any pointer to either an entity, or component or struct is only valid within a single Frame object (updates, etc).
Entity refs are safe-to-keep references to entities (temporarily replacing pointers) which work across frames, as long as the entity in question still exists.
Entity refs contain the following data internally:
- Entity type: from the DSL-defined types;
- Entity index: entity slot, from the DSL-defined maximum number for the specific type;
- Entity version number: used to render old entity refs obsolete when an entity instance is destroyed and the slot can be reused for a new one.
Updating Entity Data
To access all active (created) instances of a particular Entity type, the generated iterator accessor must be used:
C#
// this returns an iterator with pointers to all currently active Character entities
var all = f.GetAllCharacters();
// looping through the iterator
while (all.Next())
{
// retrieving the Character pointer
var c = all.Current;
// Updating data in the entity:
c->Transform2D.Position += FPVector2.Up * f.DeltaTime;
}
Updating Components Directly
An interesting approach to gameplay updates is the ability to traverse the game state through a Component type instead of an Entity.
This opens room for interesting forms of Component reuse, where a System might update its data without any knowledge about which entity types use that Component (this example applies a force to all DynamicBody components on active entities, regardless of their types):
C#
// acquiring the component buffer based on the component type (generated function).
Buffers are pooled, so the using keyword must be used to safely return them to the pool.
using (var bodies = f.GetAllDynamicBodies())
{
// a component buffer is traversed with a for loop
for (int i = 0; i < bodies.Count; i++)
{
// each entry includes the Component pointer (in this case DynamicBody)
var b = bodies[i];
// applying a force, no need to know which entity type is this
b.DynamicBody->AddForce(FPVector2.Up / 2);
// entity pointer is also available if needed (type information can be queried, etc)
var e = b.Entity;
}
}
Data-Driven Assets
A data-driven approach can be very powerful when implementing games in general, so Quantum includes a very flexible asset linking system to inject read-only data into the simulation (either for initialization or parameterized data for runtime use).
Although data-driven assets will be covered in more detail in the next chapter, the entry points for using them are Systems, so a few examples are included here.
Pre-Built Assets
Quantum contains a few pre-built data assets that are always passed into Systems through the Frame object.
These are the most important ones:
- RuntimeConfig: general game configuration data (includes an array of RuntimePlayers - explained below) which can be extended in the included "RuntimeConfig.User.cs" file;
- RuntimePlayer: good place to inject player specific runtime data (his chosen character spec, for example).
Custom data can be added to "RuntimePlayer.User.cs"; - Map: data about the playable area, static physics colliders.
Custom player data can be added from a data asset slot (will be covered in the data assets chapter);
The following snippets show how to access this data from the Frame object:
C#
RuntimeConfig config = f.RuntimeConfig;
// basic data in runtime config is the players array
for (int i = 0; i < config.Players.Length; i++)
{
RuntimePlayer p = config.Players[i];
}
// Map is the container for several static data, such as navmeshes, etc
Map map = f.Map;
var navmesh = map.NavMeshes["MyNavmesh"];
Assets Database
All Quantum data assets are available inside Systems through the "DB" static class.
The following snippets (DSL then C# code from a System) shows how to acquire a data asset from the database and assign it to an asset_ref slot into a Character:
C#
// this goes in a Quantum DSL file
asset CharacterSpec;
entity Character[8]
{
use Transform2D;
use DynamicBody;
fields
{
asset_ref<CharacterSpec> Spec;
}
}
C#
// C# code from inside a System
// grabing the data asset from the database, using a unique string ID
var spec = DB.FindAsset<CharacterData>("spec-guid");
// assigning the asset reference to the first Character entity
f.GetAllCharacters().Current->Spec = spec;
Data assets explained in more detail in their own chapter (including options on how to populate it either through Unity scriptable objects - default; custom serializers or procedurally generated content).
Signals
As explained in the previous chapter, signals are function signatures used to generate a publisher/subscriber API for inter-systems communication.
The following example in a DSL file (from the previous chapter):
C#
signal OnDamage(FP Damage);
Would lead to this trigger signal being generated on the Frame class (f variable), which can be called from "publisher" Systems:
C#
// any System can trigger the generated signal, not leading to coupling with a specific implementation
f.Signals.OnDamage(10)
A "subscriber" System would implement the generated "ISignalOnDamage" interface, which would look like this:
C#
namespace Quantum.Systems.Example
{
class CallbacksSystem : SystemBase, ISignalOnDamage
{
public void OnDamage(Frame f, Damage dmg)
{
// this will be called everytime any other system calls the OnDamage signal
}
public override void Update(Frame f)
{
}
}
}
Notice signals always include the Frame object as the first parameter, as this is normally needed to do anything useful to the game state.
Generated and Pre-Built Signals
Besides explicit signals defined directly in the DSL, Quantum also includes some pre-built ("raw" physics collision callbacks, for example) and generated ones based on the entity definitions (entity-type-specific create/destroy callbacks).
The collision callback signals will be covered in the specific chapter about the physics engine, so here's a brief description of the create/destroy signals:
- ISignalOnEntityCreated/ISignalOnEntityDestroyed: pre-built signals called whenever ANY entity is created/destroyed (params are Frame and the entity pointer in question).
The destroy signal is specially useful because entity destruction in Quantum is always deferred until the frame is finished updating (all systems updates are called); - ISignalOnENTITY_TYPECreated/ISignalOnENTITY_TYPEDestroyed (for example ISignalOnCharacterCreated): similar to the above, but passing in the type-specific pointer;
Triggering Events
Similar to what happens to signals, the entry point for triggering events is the Frame object, and each (concrete) event will result in a specific generated function (with the event data as the parameters).
C#
// taking this DSL event definition as a basis
event TriggerSound
{
FPVector2 Position;
FP Volume;
}
This can be called from a System to trigger an instance of this event (processing it from Unity will be covered on the chapter about the bootstrap project):
C#
// any System can trigger the generated events (FP._0_5 means fixed point value for 0.5)
f.Events.TriggerSound(FPVector2.Zero, FP._0_5);
Important to reinforce that events MUST NOT be used to implement gameplay itself (as the callbacks on the Unity side are not deterministic).
Events are just a one-way fine-grained API to communicate the rendering engine of detailed game state updates, so the visuals, sound and any UI-related object can be updated on Unity.
Extra Frame API Items
The Frame class also contains entry points for several other deterministic parts of the API that need to be treated as transient data (so rolled back when needed).
The following snippet shows the most important ones:
C#
// RNG is a pointer.
// Next gives a random FP between 0 and 1.
// There are also bound options for both FP and int
f.RNG->Next();
// any property defined in the global {} scope in the DSL files is accessed through the Global pointer
var d = f.Global->DeltaTime;
// input from a player is referenced by its index (i is a pointer to the DSL defined Input struct)
var i = f.GetPlayerInput(0);
Back to top