Re-usable components
Example
The full source code of the model can be downloaded here.
This page shows how to use decomposition to create smaller, re-usable components that can work together to create different types of behavior. This will be done using the components that were written on the previous pages. In doing so, we will go through the following topics:
- Using decomposition to create re-usable components.
- Using views to iterate over select entities.
- Adding convenience functions to easily create simulation objects.
By the end of the using channels page,
we have four components to create a Source-Queue-Server-Sink model.
When we run the simulation, we also get an output of how many products were received by the sink.
However, what if we want to know how many products pass through the queue and server?
And the source keeps track of the number of products it has generated,
but the SourceBehavior's Produced variable is not the same as the SinkBehavior's Received variable.
So how do we create a generic way in which we can track and retrieve this information form our simulation objects?
Adding the product tracker
Creating the tracker component
We will create a dedicated ProductTracker component.
When this component is attached to one of our simulation objects,
it will be used count the number of products that have passed through that object.
The component can be optionally added.
When it is not added to an entity, no tracking will occur for that simulation object.
But when it is added, we can easily fetch how many products have passed through each simulation object.
// ProductTracker.cs
[StructLayout(LayoutKind.Sequential)]
internal struct ProductTracker : IDataComponent
{
public ulong Seen = 0;
public ProductTracker()
{
// C# requires a constructor here
}
}
As you can see, the ProductTracker component holds the Seen variable,
which is similar to the Produced and Received variables from the source and sink.
Updating the old components
Now that we have a generic tracker component, we can update the existing components.
We need to update the Produced and Received variables of the source and sink,
but also add functionality to the other script behaviors.
Source
In SourceBehavior, we keep the Produced variable, but integrate it with the ProductTracker component.
The GenerateProductEvent now checks whether a ProductTracker component is present.
If it is, it will update the number of seen products by setting the Seen variable's value to that of Produced.
// Source.cs
internal struct GenerateProductEvent : ILocalEvent<GenerateProductEvent>
{
public Entity SourceEntity;
public void OnEvent()
{
// Same as before ...
// Only create a product when it can be moved to the next entity
if (source.CurrentProduced == CEntity.InvalidEntity())
{
// Create the product
// ...
// NEW
// If the Source has a tracker, perform tracking
if (SourceEntity.HasComponent<ProductTracker>())
{
SourceEntity.GetComponent<ProductTracker>().Value.Seen = source.Produced;
}
// Move the product to the next simulation object
// ...
}
// Same as before ...
}
}
As you can see, we first use HasComponent<ProductTracker> to check if the entity has a ProductTracker component.
When it does, the tracker component is retrieved and its data is updated.
When the tracker is not present, the source will not try to update it.
This way, the tracking of products can optionally be done per instance of the source.
Queue and Server
For the queue, we add the same if statement, but increment the Seen variable instead.
// Queue.cs
internal class QueueBehavior
{
// Same as before ...
public override void OnReceive(Entity inputChannel, Entity child)
{
Logger.Debug("Queue received {0}", child.GetName());
// NEW
// If the entity also has a ProductTracker component, track the products
if (ConnectedEntity.HasComponent<ProductTracker>())
{
ConnectedEntity.GetComponent<ProductTracker>().Value.Seen++;
}
queue.Enqueue(child);
TrySend();
if ((ulong)queue.Count >= Capacity)
{
Entity input = ConnectedEntity.GetComponent<ResourceComponent>().Value.GetInputChannel(0);
ChannelComponent.Close(input);
}
}
// Same as before ...
}
For the server, we do the same:
// Server.cs
internal class ServerBehavior : ScriptBehaviorComponent
{
// Same as before ...
public override void OnReceive(Entity inputChannel, Entity child)
{
Logger.Debug("Server received {0}", child.GetName());
// NEW
// If the entity also has a ProductTracker component, track the products
if (ConnectedEntity.HasComponent<ProductTracker>())
{
ConnectedEntity.GetComponent<ProductTracker>().Value.Seen++;
}
CurrentlyProcessing = child;
Entity input = ConnectedEntity.GetComponent<ResourceComponent>().Value.GetInputChannel(0);
ChannelComponent.Close(input);
Process();
}
// Same as before ...
}
Sink
For the sink, we remove the Received variable and update the ProductTracker instead:
internal class SinkBehavior : ScriptBehaviorComponent
{
// REMOVED: public ulong Received = 0;
public override void OnEntered(Entity newChild)
{
// If the entity also has a ProductTracker component, track the products
if (ConnectedEntity.HasComponent<ProductTracker>())
{
ConnectedEntity.GetComponent<ProductTracker>().Value.Seen++;
}
SubModel.Get().DestroyEntity(newChild);
}
}
Updating the model setup
Let's now update the model setup and use the tracker component. We will register the new component and update the creation of the simulation objects.
// Register the custom components
ComponentRegistry<SourceBehavior>.Register();
ComponentRegistry<QueueBehavior>.Register();
ComponentRegistry<ServerBehavior>.Register();
ComponentRegistry<SinkBehavior>.Register();
ComponentRegistry<Product>.Register();
ComponentRegistry<ProductTracker>.Register(); // NEW
// Register the local events
LocalEventRegistry<GenerateProductEvent>.Register();
LocalEventRegistry<ServerProcessEvent>.Register();
// ModelContainer is created as before...
// Create the simulation objects
Entity sourceEntity = subModel.CreateEntity("Source");
SourceBehavior source = sourceEntity.AddComponent<SourceBehavior>();
sourceEntity.AddComponent<ResourceComponent>();
Entity sourceOutputEntity = subModel.CreateEntity(sourceEntity, "SourceOutput");
ChannelComponent.AddChannelComponent(sourceOutputEntity, ChannelType.Output, sourceEntity);
sourceEntity.AddComponent<ProductTracker>(); // NEW
Entity queueEntity = subModel.CreateEntity("Queue");
queueEntity.AddComponent<QueueBehavior>();
queueEntity.AddComponent<ResourceComponent>();
Entity queueInputEntity = subModel.CreateEntity(queueEntity, "QueueInput");
Entity queueOutputEntity = subModel.CreateEntity(queueEntity, "QueueOutput");
ChannelComponent.AddChannelComponent(queueInputEntity, ChannelType.Input, queueEntity);
ChannelComponent.AddChannelComponent(queueOutputEntity, ChannelType.Output, queueEntity);
queueEntity.AddComponent<ProductTracker>(); // NEW
Entity serverEntity = subModel.CreateEntity("Server");
serverEntity.AddComponent<ServerBehavior>();
serverEntity.AddComponent<ResourceComponent>();
Entity serverInputEntity = subModel.CreateEntity(serverEntity, "ServerInput");
Entity serverOutputEntity = subModel.CreateEntity(serverEntity, "ServerOutput");
ChannelComponent.AddChannelComponent(serverInputEntity, ChannelType.Input, serverEntity);
ChannelComponent.AddChannelComponent(serverOutputEntity, ChannelType.Output, serverEntity);
serverEntity.AddComponent<ProductTracker>(); // NEW
Entity sinkEntity = subModel.CreateEntity("Sink");
SinkBehavior sink = sinkEntity.AddComponent<SinkBehavior>();
sinkEntity.AddComponent<ResourceComponent>();
Entity sinkInputEntity = subModel.CreateEntity(sinkEntity, "SinkInput");
ChannelComponent.AddChannelComponent(sinkInputEntity, ChannelType.Input, sinkEntity);
sinkEntity.AddComponent<ProductTracker>(); // NEW
// Connect the simulation objects as before...
As you can see, we add the product tracking functionality to each simulation object using the new ProductTracker component.
Using views
So far, our model has always printed the number of products the sink received by the end of the simulation:
Logger.Info($"Sink received {sink.Received} products");
But now we want to log this information for each of the simulation objects.
Previously, we would have to add a Received counter to each component and use GetComponent on each entity to get all components one-by-one to read the received value.
However, we will now use ProductTracker component and create a generalized logging system.
We can use views to iterate over all entities that have certain components attached to them.
We replace the Logger.Info mentioned at the beginning of this section with the view.
This view iterates over all entities that have a ProductTracker component attached to them.
Notice the [] (empty list) as an argument to the GetView function.
This is where you would place any components you would want to exclude.
For more information about this, see the views section of the ECS page.
// Program.cs
// Log how many products each of the simulation objects saw
sim.EnterSubModel();
var trackerView = subModel.GetView<ProductTracker>([]);
while (trackerView.Next())
{
Entity entity = trackerView.GetEntity();
var tracker = trackerView.GetComponent<ProductTracker>();
Logger.Info($"{entity.GetName()} saw {tracker.Value.Seen} products");
}
trackerView.Dispose();
sim.ExitSubModel();
Notice how the view is explicitly disposed. This is unique to C#, because the garbage collector does not always reliably free the views resources.
Also notice that we need to enter and exit the submodel again to be able to use it outside of the runtime of the model.
If we run the model now, we see the new log messages made by the view. As you can see, the output is unchanged (the sink still received the same number of products as before). If we were to add more simulation objects that contain channels, they will be automatically listed here too.
[I] Thread [36032] Starting Enterprise Resource Simulator Core
[I] Thread [36032] Version 0.7.0 | Build Date: 2026-04-23 15:13:41
[I] Thread [36032] Reading initial settings file
[I] Thread [36032] [Info] is new LogLevel!
[I] Thread [36032] [Info] is new LogLevel!
[I] Thread [36032] Source saw 368 products
[I] Thread [36032] Queue saw 367 products
[I] Thread [36032] Server saw 362 products
[I] Thread [36032] Sink saw 361 products
[I] Thread [36032] Waiting for JobSystem to finish remaining tasks, then shut down
[I] Thread [36032] Exiting JobSystem
[I] Thread [36032] Closing Enterprise Resource Simulator Core
Convenient simulation object creation
Let's also make it easier to create our simulation objects. If we want to add more objects to our model, we need to write multiple lines of code for each object:
Entity queueEntity = subModel.CreateEntity("Queue");
queueEntity.AddComponent<QueueBehavior>();
queueEntity.AddComponent<ResourceComponent>();
Entity queueInputEntity = subModel.CreateEntity(queueEntity, "QueueInput");
Entity queueOutputEntity = subModel.CreateEntity(queueEntity, "QueueOutput");
ChannelComponent.AddChannelComponent(queueInputEntity, ChannelType.Input, queueEntity);
ChannelComponent.AddChannelComponent(queueOutputEntity, ChannelType.Output, queueEntity);
queueEntity.AddComponent<ProductTracker>();
As you can image, this gets pretty annoying. And if we ever change the setup of a simulation object, it needs to be changed everywhere we do this. Instead, let's add a convenience function to each script behavior to easily create a simulation object with all required components.
Updating the script behaviors
We will add a static Create function to each script behavior.
These functions will create the entity and attach all components that are necessary.
Additionally, the function may take some parameters to be able to configure the simulation object.
These function will return the script behavior, and any channel entities of the object.
That way, the simulation can be further configured after this function is called.
Note
Of course, this function does not necessarily need to be part of the script behavior. It could be part of a different class or be a free function, but it depends on what style you prefer to use.
We move the code from Program.cs to Source.cs, into the Create function:
// Source.cs
internal class SourceBehavior : ScriptBehaviorComponent
{
public ulong GenerationTime = 5;
public ulong Produced = 0;
public Entity CurrentProduced = CEntity.InvalidEntity();
// NEW
public static (SourceBehavior, Entity) Create(string name = "Source", bool addTracker = true)
{
SubModel subModel = SubModel.Get();
Entity sourceEntity = subModel.CreateEntity(name);
SourceBehavior source = sourceEntity.AddComponent<SourceBehavior>();
// Channels
sourceEntity.AddComponent<ResourceComponent>();
Entity sourceOutputEntity = subModel.CreateEntity(sourceEntity, $"{name}-Output");
ChannelComponent.AddChannelComponent(sourceOutputEntity, ChannelType.Output, sourceEntity);
if (addTracker)
sourceEntity.AddComponent<ProductTracker>();
return (source, sourceOutputEntity);
}
// Same as before...
}
As you can see, this function re-acquires the submodel so we do not need to pass the submodel from Program.cs.
Entering and exiting submodels works like a stack,
so because we entered a submodel in Program.cs,
we can acquire the same submodel again using the SubModel::Get function.
We also give the Create function optional name and addTracking parameters with default values.
So if Create is called without parameters,
the default name is used and a ProductTracker is automatically added to the simulation object.
The name parameter allows us to give unique names to each simulation object,
while the addTracking parameter allows one to exclude product tracking when desired.
We do the same with the queue, server, and sink. First the queue:
// Queue.cs
internal class QueueBehavior : ScriptBehaviorComponent
{
public ulong Capacity = 5;
private readonly Queue<Entity> queue = new();
// NEW
public static (QueueBehavior, Entity, Entity) Create(string name = "Queue", bool addTracker = true)
{
SubModel subModel = SubModel.Get();
Entity queueEntity = subModel.CreateEntity(name);
QueueBehavior queue = queueEntity.AddComponent<QueueBehavior>();
// Channels
queueEntity.AddComponent<ResourceComponent>();
Entity queueInputEntity = subModel.CreateEntity(queueEntity, $"{name}-Input");
Entity queueOutputEntity = subModel.CreateEntity(queueEntity, $"{name}-Output");
ChannelComponent.AddChannelComponent(queueInputEntity, ChannelType.Input, queueEntity);
ChannelComponent.AddChannelComponent(queueOutputEntity, ChannelType.Output, queueEntity);
if (addTracker)
queueEntity.AddComponent<ProductTracker>();
return (queue, queueInputEntity, queueOutputEntity);
}
// Same as before...
}
Then the server:
// Server.cs
internal class ServerBehavior : ScriptBehaviorComponent
{
public double BaseProcessTime = 10.0;
public Entity CurrentlyProcessing = CEntity.InvalidEntity();
// NEW
public static (ServerBehavior, Entity, Entity) Create(string name = "Server", bool addTracker = true)
{
SubModel subModel = SubModel.Get();
Entity serverEntity = subModel.CreateEntity(name);
ServerBehavior server = serverEntity.AddComponent<ServerBehavior>();
// Channels
serverEntity.AddComponent<ResourceComponent>();
Entity serverInputEntity = subModel.CreateEntity(serverEntity, $"{name}-Input");
Entity serverOutputEntity = subModel.CreateEntity(serverEntity, $"{name}-Output");
ChannelComponent.AddChannelComponent(serverInputEntity, ChannelType.Input, serverEntity);
ChannelComponent.AddChannelComponent(serverOutputEntity, ChannelType.Output, serverEntity);
if (addTracker)
serverEntity.AddComponent<ProductTracker>();
return (server, serverInputEntity, serverOutputEntity);
}
// Same as before...
}
And lastly, the sink:
// Sink.cs
internal class SinkBehavior : ScriptBehaviorComponent
{
// NEW
public static (SinkBehavior, Entity) Create(string name = "Sink", bool addTracker = true)
{
SubModel subModel = SubModel.Get();
Entity sinkEntity = subModel.CreateEntity(name);
SinkBehavior sink = sinkEntity.AddComponent<SinkBehavior>();
// Channels
sinkEntity.AddComponent<ResourceComponent>();
Entity sinkInputEntity = subModel.CreateEntity(sinkEntity, $"{name}-Input");
ChannelComponent.AddChannelComponent(sinkInputEntity, ChannelType.Input, sinkEntity);
if (addTracker)
sinkEntity.AddComponent<ProductTracker>();
return (sink, sinkInputEntity);
}
// Same as before...
}
Updating the model setup again
Let's now use the new Create functions to set up our model:
// Program.cs
// Model is created as before...
sim.EnterSubModel();
// Create the simulation objects
(_, Entity sourceOutputEntity) = SourceBehavior.Create();
(_, Entity queueInputEntity, Entity queueOutputEntity) = QueueBehavior.Create();
(_, Entity serverInputEntity, Entity serverOutputEntity) = ServerBehavior.Create();
(_, Entity sinkInputEntity) = SinkBehavior.Create();
// Simulation objects are connected as before...
sim.ExitSubModel();
// ModelContainer is updated as before...
As you can see, adding a simulation object now only requires one function call,
and the channels between the simulation objects are connected in the same way as before.
It will now be a lot easier to create a different model using our simulation objects.
For example, adding second queue is now as simple as calling QueueBehavior::Create a second time and connected its channels.
Also, in our case, we do not use the script behaviors returned by the Create functions.
But it may be possible that they need to be accessed.
For example, to configure the generation time of a source, or to get the ConnectedEntity.
Next steps
- Continue with scaling up.