Since using Entity Component System architecture for game design I’ve noticed a number of common patterns that are useful for solving different types of problems the “ECS” way. In this article I want to talk about what I’m calling the Single Fire Component pattern.
Here’s a scenario. Your character is walking around the screen and they enter a trigger. In an ECS world how do you deal with this?
The answer is to provide a mechanism so that when the entity (the character) enters the trigger, a single use component is added to that entity. Then a system that is watching for that component fires, and at the end of that update that system (or a dedicated cleanup system) takes that component off the entity.
Components don’t always need to contain data. Sometimes they can just act as flags for a system to either respond to, or ignore, a given entity. In this case we want to use the component as a positive flag so the system knows when to act on an entity.
Teleport System
Let’s go through an example. In the game I’m working on I needed a way to teleport things once they entered a trigger. The teleport is triggered from a collision, and moves the position of the entity to a fixed point.
Adding the component
First, I need a way to add the component
to the entity
on a trigger. This is done with a MonoBehaviour
as right now there’s no pure ECS way to use physics or colliders.
[Serializable]
public struct Transition : IComponentData
{
public float2 Location;
}
[RequireComponent(typeof(Collider2D))]
public class AddTransitionComponentOnTrigger : MonoBehaviour
{
// The location to set the transform too.
[SerializeField] private Transform location;
void OnTriggerEnter2D(Collider2D collider)
{
// Get the entity part of the object that collided with the trigger.
var entityComponent = collider.gameObject.GetComponent<GameObjectEntity>();
// Add the component to the entity.
entityComponent.EntityManager.AddComponentData(entityComponent.Entity,
new Transition {
Location = new float2(location.position.x, location.position.y)
});
}
}
Using the component
Now that the entity
has a component
added to it, a system
can take over and perform some task. In our case we want the system
to update the entities
position based on the data in the Transition component
.
[UpdateAfter(typeof(MoveForward2DSystem))]
public class TransitionSystem : JobComponentSystem
{
}
First, we want the system to copy the Transition
data from the component to the Position2D
of the entity.
[ComputeJobOptimization]
struct CopyTransition : IJobProcessComponentData<Transition, Position2D>
{
public void Execute(
[ReadOnly]ref Transition transition,
ref Position2D position) =>
position = new Position2D { Value = transition.Location };
}
Then, we want to remove the Transition
component from the entity.
struct RemoveTransition : IJob
{
[ReadOnly] public EntityArray entities;
public EntityCommandBuffer entityCommandBuffer;
public void Execute()
{
for (int i = 0; i < entities.Length; i++)
entityCommandBuffer.RemoveComponent<Transition>(entities[i]);
}
}
Why do we need to use a command buffer to remove the component? When you’re in a job, you can’t access the entity manager, so you cannot remove a component using that. A job must use an EntityCommandBuffer
to remove the component instead.
And now we schedule those jobs in the TransitionSystem
.
[UpdateAfter(typeof(MoveForward2DSystem))]
public class TransitionSystem : JobComponentSystem
{
[ComputeJobOptimization]
struct CopyTransition : IJobProcessComponentData<Transition, Position2D> { ... }
struct RemoveTransition : IJob { ... }
EndFrameBarrier endFrameBarrier;
ComponentGroup componentGroup;
protected override void OnCreateManager(int capacity)
{
endFrameBarrier = World.GetOrCreateManager<EndFrameBarrier>();
componentGroup = GetComponentGroup(
ComponentType.ReadOnly(typeof(Transition)),
typeof(Position2D));
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var entities = componentGroup.GetEntityArray();
var copyTransitionJob = new CopyTransition();
var copyTransitionJobHandle = copyTransitionJob.Schedule(this);
var removeComponentsJob = new RemoveTransition
{
entities = entities,
entityCommandBuffer = endFrameBarrier.CreateCommandBuffer()
};
var removeComponentsJobHandle =
removeComponentsJob.Schedule(copyTransitionJobHandle);
return removeComponentsJobHandle;
}
}
Notice how the CopyTransition
job is chained to the RemoveTransition
job so they run in that order.
Extracting the pattern
This single-fire idea seems useful. So let’s take our system and make it reusable.
public abstract class SingleFireSystem<TComponent, TBarrier> : JobComponentSystem
where TComponent : IComponentData
where TBarrier : BarrierSystem
{
struct RemoveComponentJob : IJob
{
[ReadOnly] public EntityArray entities;
public EntityCommandBuffer entityCommandBuffer;
public void Execute()
{
for (int i = 0; i < entities.Length; i++)
entityCommandBuffer.RemoveComponent<TComponent>(entities[i]);
}
}
protected ComponentGroup group;
protected BarrierSystem barrier;
protected abstract JobHandle CreateJobHandle(JobHandle inputDeps);
protected override void OnCreateManager(int capacity)
{
barrier = World.GetOrCreateManager<TBarrier>();
group = GetComponentGroup(ComponentType.ReadOnly(typeof(TComponent)));
}
protected override JobHandle OnUpdate(JobHandle inputDeps)
{
var jobHandle = CreateJobHandle(inputDeps);
var removeJob = new RemoveComponentJob
{
entities = group.GetEntityArray(),
entityCommandBuffer = barrier.CreateCommandBuffer()
};
var removeJobHandle = removeJob.Schedule(jobHandle);
return removeJobHandle;
}
}
And now we can reuse this to create single-fire systems.
[UpdateAfter(typeof(MoveForward2DSystem))]
public class TransitionSystem : SingleFireSystem<Transition, EndFrameBarrier>
{
[ComputeJobOptimization]
struct CopyTransition : IJobProcessComponentData<Transition, Position2D>
{
public void Execute(
[ReadOnly]ref Transition transition,
ref Position2D position) =>
position = new Position2D { Value = transition.Location };
}
protected override JobHandle CreateJobHandle(JobHandle inputDeps)
{
var job = new CopyTransition();
return job.Schedule(this, inputDeps);
}
}
Conclusion
Now we’ve got a reusable system for handling tasks when we need something that only works for one update. A useful system for triggers, or collisions, etc.