Systems Guide
Systems contain the logic that processes entities. This guide covers system design patterns and best practices.
What is a System?
A system is a class that:
- Queries entities with specific components
- Processes those entities each frame/tick
- May modify component data or trigger side effects
Systems implement ISystem or extend SystemBase:
public interface ISystem : IDisposable
{
bool Enabled { get; set; }
void Initialize(IWorld world);
void Update(float deltaTime);
}
Creating Systems
Using SystemBase
The recommended way to create systems:
public class MovementSystem : SystemBase
{
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<Position, Velocity>())
{
ref var pos = ref World.Get<Position>(entity);
ref readonly var vel = ref World.Get<Velocity>(entity);
pos.X += vel.X * deltaTime;
pos.Y += vel.Y * deltaTime;
}
}
}
SystemBase provides:
Worldproperty for accessing the world- Lifecycle hooks (
OnInitialize,OnBeforeUpdate,OnAfterUpdate,OnEnabled,OnDisabled) Enabledproperty for runtime controlDispose()for cleanup
Implementing ISystem
For more control, implement ISystem directly:
public class CustomSystem : ISystem
{
private IWorld? world;
public bool Enabled { get; set; } = true;
public void Initialize(IWorld world)
{
this.world = world;
// Setup code
}
public void Update(float deltaTime)
{
// Update code
}
public void Dispose()
{
// Cleanup code
}
}
Registering Systems
Register systems with the world:
using var world = new World();
// Register individual systems
world.AddSystem<InputSystem>();
world.AddSystem<MovementSystem>();
world.AddSystem<RenderSystem>();
// Or chain registrations
world
.AddSystem<InputSystem>()
.AddSystem<PhysicsSystem>()
.AddSystem<MovementSystem>()
.AddSystem<AnimationSystem>()
.AddSystem<RenderSystem>();
System Execution
Update Loop
Call World.Update() to run all systems:
// Game loop
while (running)
{
float deltaTime = CalculateDeltaTime();
world.Update(deltaTime);
}
Execution Order
Systems execute based on phase and order:
- Phase - Which stage of the frame (EarlyUpdate → PostRender)
- Order - Priority within a phase (lower values run first)
// Specify phase and order when adding systems
world.AddSystem<InputSystem>(SystemPhase.EarlyUpdate, order: 0);
world.AddSystem<PhysicsSystem>(SystemPhase.FixedUpdate, order: 0);
world.AddSystem<MovementSystem>(SystemPhase.Update, order: 10);
world.AddSystem<AISystem>(SystemPhase.Update, order: 20);
world.AddSystem<RenderSystem>(SystemPhase.Render, order: 0);
System Phases
| Phase | Description |
|---|---|
EarlyUpdate |
Start of frame - input polling, time updates |
FixedUpdate |
Fixed timestep - physics simulation |
Update |
Main game logic (default phase) |
LateUpdate |
After main update - cameras, cleanup |
Render |
Rendering phase |
PostRender |
After rendering - debug overlays, profiling |
Using the [System] Attribute
Declare phase and order metadata with the [System] attribute:
[System(Phase = SystemPhase.EarlyUpdate, Order = 0)]
public partial class InputSystem : SystemBase
{
public override void Update(float deltaTime)
{
// Poll input devices
}
}
[System(Phase = SystemPhase.Update, Order = 10)]
public partial class MovementSystem : SystemBase
{
public override void Update(float deltaTime)
{
// Process movement
}
}
[System(Phase = SystemPhase.Update, Order = 20, Group = "AI")]
public partial class AISystem : SystemBase
{
public override void Update(float deltaTime)
{
// AI decision-making runs after movement
}
}
The source generator creates static metadata properties from the attribute. You can also override the attribute values when registering:
// Use attribute defaults
world.AddSystem<MovementSystem>();
// Override at registration time
world.AddSystem<MovementSystem>(SystemPhase.LateUpdate, order: 100);
System Dependencies with [RunBefore] and [RunAfter]
For explicit ordering constraints between systems, use the [RunBefore] and [RunAfter] attributes:
[System(Phase = SystemPhase.Update)]
[RunAfter(typeof(InputSystem))]
public partial class MovementSystem : SystemBase
{
public override void Update(float deltaTime)
{
// Always runs after InputSystem in the Update phase
}
}
[System(Phase = SystemPhase.Update)]
[RunBefore(typeof(RenderSystem))]
[RunAfter(typeof(MovementSystem))]
public partial class AnimationSystem : SystemBase
{
public override void Update(float deltaTime)
{
// Runs after MovementSystem but before RenderSystem
}
}
You can also specify dependencies at registration time:
world.AddSystem<CollisionSystem>(
SystemPhase.Update,
order: 0,
runsBefore: [typeof(DamageSystem)],
runsAfter: [typeof(MovementSystem)]);
Key points:
- Dependencies only apply within the same phase
- Cross-phase references are ignored (phase order takes precedence)
- Dependencies override Order values when they conflict
- Systems use topological sorting to resolve the final execution order
Cycle Detection
If system dependencies create a cycle, an InvalidOperationException is thrown:
// This will throw - A runs before B, B runs before A
[System(Phase = SystemPhase.Update)]
[RunBefore(typeof(SystemB))]
public partial class SystemA : SystemBase { }
[System(Phase = SystemPhase.Update)]
[RunBefore(typeof(SystemA))] // Creates a cycle!
public partial class SystemB : SystemBase { }
The exception message indicates which phase contains the cycle and which systems are involved.
Fixed Update
For physics or other time-sensitive systems, use World.FixedUpdate() which only executes systems in the FixedUpdate phase:
const float fixedDeltaTime = 1f / 60f; // 60 Hz
float accumulator = 0f;
while (running)
{
float deltaTime = CalculateDeltaTime();
accumulator += deltaTime;
// Run physics at fixed timestep
while (accumulator >= fixedDeltaTime)
{
world.FixedUpdate(fixedDeltaTime); // Only FixedUpdate phase systems
accumulator -= fixedDeltaTime;
}
// Run all other systems at variable rate
world.Update(deltaTime); // Runs all phases including FixedUpdate
}
Note: World.Update() runs ALL phases (including FixedUpdate). Use World.FixedUpdate() when you need to run physics separately at a fixed timestep.
Lifecycle Hooks
SystemBase provides lifecycle hooks for fine-grained control over system execution:
| Hook | When Called |
|---|---|
OnInitialize() |
After system is added to a world |
OnBeforeUpdate(deltaTime) |
Before each Update() call |
OnAfterUpdate(deltaTime) |
After each Update() call |
OnEnabled() |
When system transitions from disabled to enabled |
OnDisabled() |
When system transitions from enabled to disabled |
Example: Fixed Timestep Physics
public class PhysicsSystem : SystemBase
{
private float accumulator;
private const float FixedTimeStep = 1f / 60f;
protected override void OnBeforeUpdate(float deltaTime)
{
// Accumulate time for fixed timestep simulation
accumulator += deltaTime;
}
public override void Update(float deltaTime)
{
// Run physics at fixed timestep
while (accumulator >= FixedTimeStep)
{
SimulatePhysics(FixedTimeStep);
accumulator -= FixedTimeStep;
}
}
private void SimulatePhysics(float dt)
{
foreach (var entity in World.Query<Position, Velocity>().Without<Static>())
{
ref var pos = ref World.Get<Position>(entity);
ref readonly var vel = ref World.Get<Velocity>(entity);
pos.X += vel.X * dt;
pos.Y += vel.Y * dt;
}
}
}
Example: Resource Management
public class AudioSystem : SystemBase
{
private AudioEngine? engine;
protected override void OnInitialize()
{
engine = new AudioEngine();
}
protected override void OnEnabled()
{
engine?.Resume();
}
protected override void OnDisabled()
{
engine?.Pause();
}
public override void Update(float deltaTime)
{
engine?.Update(deltaTime);
}
public override void Dispose()
{
engine?.Dispose();
base.Dispose();
}
}
Runtime Control
Enable or disable systems at runtime for performance optimization or game state management.
Enabled Property
var system = world.GetSystem<AISystem>();
system.Enabled = false; // Pauses AI processing
// Later...
system.Enabled = true; // Resumes AI processing
World API
// Get a system by type
var physics = world.GetSystem<PhysicsSystem>();
// Enable/disable by type
world.DisableSystem<RenderSystem>(); // Returns true if found
world.EnableSystem<RenderSystem>();
// Works with nested SystemGroups
world.GetSystem<AISystem>(); // Searches all groups recursively
Pausing Game Systems
public class GamePauseManager
{
private readonly World world;
private readonly List<Type> pausableSystems =
[
typeof(PhysicsSystem),
typeof(AISystem),
typeof(AnimationSystem)
];
public void Pause()
{
world.DisableSystem<PhysicsSystem>();
world.DisableSystem<AISystem>();
world.DisableSystem<AnimationSystem>();
}
public void Resume()
{
world.EnableSystem<PhysicsSystem>();
world.EnableSystem<AISystem>();
world.EnableSystem<AnimationSystem>();
}
}
SystemGroup Control
Disable an entire group to pause all contained systems:
var combatGroup = new SystemGroup("Combat")
.Add<TargetingSystem>()
.Add<DamageSystem>()
.Add<HealthSystem>();
world.AddSystem(combatGroup);
// Disable all combat systems at once
combatGroup.Enabled = false;
System Patterns
Query-Based Processing
The most common pattern:
public class GravitySystem : SystemBase
{
private const float Gravity = -9.81f;
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<Velocity>().Without<Static>())
{
ref var vel = ref World.Get<Velocity>(entity);
vel.Y += Gravity * deltaTime;
}
}
}
Using CommandBuffer
For spawning/despawning during iteration:
public class DeathSystem : SystemBase
{
private readonly CommandBuffer buffer = new();
public override void Update(float deltaTime)
{
foreach (var entity in World.Query<Health>())
{
ref readonly var health = ref World.Get<Health>(entity);
if (health.Current <= 0)
{
buffer.Despawn(entity);
}
}
buffer.Flush(World);
}
}
Using Singletons
Access world-level data:
public class TimeSystem : SystemBase
{
public override void Update(float deltaTime)
{
ref var time = ref World.GetSingleton<GameTime>();
time.DeltaTime = deltaTime;
time.TotalTime += deltaTime;
time.FrameCount++;
}
}
public class AnimationSystem : SystemBase
{
public override void Update(float deltaTime)
{
ref readonly var time = ref World.GetSingleton<GameTime>();
foreach (var entity in World.Query<Animation>())
{
ref var anim = ref World.Get<Animation>(entity);
anim.CurrentFrame = (int)(time.TotalTime * anim.FrameRate) % anim.FrameCount;
}
}
}
System Initialization
Use OnInitialize() for one-time setup:
public class AISystem : SystemBase
{
private NavMesh? navMesh;
protected override void OnInitialize()
{
// Load resources, build data structures
navMesh = NavMesh.LoadFrom("level.nav");
// Set up singletons
World.SetSingleton(new AIConfig { UpdateRate = 0.1f });
}
public override void Update(float deltaTime)
{
// Use initialized resources
}
public override void Dispose()
{
// Cleanup
navMesh?.Dispose();
}
}
Inter-System Communication
Systems can communicate through several mechanisms:
- Messaging - Decoupled publish-subscribe communication:
// Define a message type
public readonly record struct DamageMessage(Entity Target, int Amount, Entity Source);
// Subscribe to messages (typically in OnInitialize)
private EventSubscription? damageSub;
protected override void OnInitialize()
{
damageSub = World.Subscribe<DamageMessage>(HandleDamage);
}
private void HandleDamage(DamageMessage msg)
{
ref var health = ref World.Get<Health>(msg.Target);
health.Current -= msg.Amount;
}
// Send messages from another system
World.Send(new DamageMessage(target, 25, attacker));
// Or queue for deferred processing
World.QueueMessage(new DamageMessage(target, 25, attacker));
World.ProcessQueuedMessages(); // Process all queued messages
See the Messaging Guide for comprehensive coverage.
- Singletons - World-level shared state:
// InputSystem writes
World.SetSingleton(new InputState { MoveDirection = dir });
// MovementSystem reads
ref readonly var input = ref World.GetSingleton<InputState>();
- Components - Entity-level data:
// DamageSystem adds damage events
World.Add(entity, new DamageEvent { Amount = 10, Source = attacker });
// HealthSystem processes and removes
foreach (var entity in World.Query<Health, DamageEvent>())
{
ref var health = ref World.Get<Health>(entity);
ref readonly var damage = ref World.Get<DamageEvent>(entity);
health.Current -= damage.Amount;
World.Remove<DamageEvent>(entity);
}
- Tags - State flags:
// CombatSystem marks entity
World.Add(entity, default(InCombat));
// AISystem checks flag
foreach (var entity in World.Query<AI>().With<InCombat>())
{
// Combat AI behavior
}
System Organization
By Feature
Group related functionality:
// Movement feature
world.AddSystem<InputSystem>();
world.AddSystem<PhysicsSystem>();
world.AddSystem<MovementSystem>();
// Combat feature
world.AddSystem<TargetingSystem>();
world.AddSystem<CombatSystem>();
world.AddSystem<DamageSystem>();
// Rendering feature
world.AddSystem<AnimationSystem>();
world.AddSystem<RenderSystem>();
By Update Phase
// Early update - input and AI
world.AddSystem<InputSystem>();
world.AddSystem<AISystem>();
// Main update - game logic
world.AddSystem<MovementSystem>();
world.AddSystem<CombatSystem>();
world.AddSystem<PhysicsSystem>();
// Late update - cleanup and rendering
world.AddSystem<DeathSystem>();
world.AddSystem<RenderSystem>();
System Hooks
Global system hooks enable cross-cutting concerns like profiling, logging, and metrics collection without modifying individual systems.
Adding Hooks
Register hooks with World.AddSystemHook():
// Profiling hook
var hookSub = world.AddSystemHook(
beforeHook: (system, dt) => /* start timer */,
afterHook: (system, dt) => /* record elapsed time */
);
// Dispose to remove the hook
hookSub.Dispose();
Hook Patterns
Performance Profiling:
var timings = new Dictionary<string, float>();
var hookSub = world.AddSystemHook(
beforeHook: (system, dt) => stopwatch.Restart(),
afterHook: (system, dt) =>
{
stopwatch.Stop();
timings[system.GetType().Name] = stopwatch.ElapsedMilliseconds;
}
);
Debug Logging:
var hookSub = world.AddSystemHook(
beforeHook: (system, dt) => logger.Debug($"Starting {system.GetType().Name}"),
afterHook: (system, dt) => logger.Debug($"Finished {system.GetType().Name}")
);
Phase Filtering:
// Hook only runs for FixedUpdate phase systems
var hookSub = world.AddSystemHook(
beforeHook: (system, dt) => /* ... */,
afterHook: null,
phase: SystemPhase.FixedUpdate
);
Plugin Integration
Plugins can register and clean up hooks during their lifecycle:
public class ProfilingPlugin : IWorldPlugin
{
public string Name => "Profiling";
private EventSubscription? hookSubscription;
public void Install(IPluginContext context)
{
hookSubscription = context.World.AddSystemHook(
beforeHook: (system, dt) => /* start profiling */,
afterHook: (system, dt) => /* end profiling */
);
}
public void Uninstall(IPluginContext context)
{
hookSubscription?.Dispose();
}
}
Performance Considerations
- No hooks registered: Minimal overhead (~2-3ns per system)
- With hooks: Overhead scales linearly with hook count
- Phase filtering: Reduces unnecessary hook invocations
Best Practices
- Dispose subscriptions when hooks are no longer needed
- Keep hooks lightweight - they run for every system execution
- Use phase filtering when hooks only apply to specific phases
- Handle exceptions - hook exceptions propagate to the caller
Best Practices
Do: Single Responsibility
// ✅ Good: Focused systems
public class MovementSystem : SystemBase { /* movement only */ }
public class GravitySystem : SystemBase { /* gravity only */ }
public class CollisionSystem : SystemBase { /* collision only */ }
// ❌ Bad: God system
public class EverythingSystem : SystemBase { /* movement + gravity + collision + ... */ }
Do: Use ref for Component Access
// ✅ Good: Zero-copy access
ref var pos = ref World.Get<Position>(entity);
pos.X += 1;
// ❌ Bad: Copies data unnecessarily
var pos = World.Get<Position>(entity); // Creates a copy
pos.X += 1; // Modifies the copy, not the original!
Don't: Modify Entities During Iteration
// ❌ Bad: Modifying during iteration
foreach (var entity in World.Query<Health>())
{
if (health.Current <= 0)
World.Despawn(entity); // Invalidates iterator!
}
// ✅ Good: Use CommandBuffer
var buffer = new CommandBuffer();
foreach (var entity in World.Query<Health>())
{
if (health.Current <= 0)
buffer.Despawn(entity);
}
buffer.Flush(World);
Do: Keep Systems Stateless When Possible
// ✅ Good: Stateless system
public class MovementSystem : SystemBase
{
public override void Update(float deltaTime)
{
// All state is in components/singletons
}
}
// Be careful with: Stateful systems
public class TimerSystem : SystemBase
{
private float elapsed; // State in system - harder to serialize/debug
public override void Update(float deltaTime)
{
elapsed += deltaTime;
}
}
Next Steps
- Messaging Guide - Inter-system communication patterns
- Queries Guide - Query patterns for systems
- Command Buffer - Deferred entity operations
- Singletons Guide - World-level resources