Skip to content

Using channels

Example

The full source code of the model can be downloaded here.

This page uses channels to create more generalized simulation objects. This will be done using the components that were written on the previous pages. In doing so, we will go through the following topics:

  • Adding channels to simulation objects and connecting them
  • Sending entities through channels
  • Receiving entities through channels

By the end of the expanding the model page, we have four components to create a Source-Queue-Server-Sink model. However, the components have their connections hard coded: the source always sends to the queue, the queue always to the server, and the server always to the sink. We want to be able to re-configure the model layout while using the same components (and without hard coding them again).

To do this, we will use channels. Channels are a way to move entities from one simulation object to another without having to know exactly what the simulation object is. They come in two types: an input-channel that can send entities, and an output-channel that can receive entities. Each input- and output-channel form a pair (a one-to-one connection). Instead of moving an entity directly from one parent to another, an entity is sent through an output-channel to its connected input-channel. Channels can also be closed to prevent entities from moving. This can be useful when an object like the queue is full, or the server is still processing the previous object. When both the connected input- and output-channel are set to open, certain callback functions are triggered. Thanks to these features, simulation objects do not need to know to which type of object they are moving an entity to.

How channels work in ERS

In ERS, each channel is actually a child entity to the object that has channels. Each simulation object can have multiple channels by adding more channel child entities. These child entities each have a ChannelComponent, which holds the state and relevant data for the channel to function. The parent entity has a ResourceComponent, which can be used to retrieve the channels of the simulation object. For more information, see the channels page.

The reason to use child entities for the channels is so they can be given different properties too. For example, by adding a TransformComponent to a channel entity, it get the properties of the TransformComponent. This way, the channel can be visualized with a position relative to its parent. Additionally, other components may be used to extend the functionality of the channel entity. These components may contain data that is relevant to check whether an entity can move through the channel.

Both input- and output-channels can be "open". When a channel is open, it is able to either receive (input) or send (output) entities. An input-output channel pair can also be "ready", meaning it is able to move an entity through it. Channels are considered ready when both the connected input- and output-channel are open. When an input-output pair becomes ready, the OnOutputChannelReady and OnInputChannelReady callbacks on the script behavior of the channels' parents are called. For the channel that has the input channel OnInputChannelReady is called, and for the entity that has the output channel OnOutputChannelReady is called.

Updating the old components

Let's first update the existing simulation objects' implementations. Later, we will update the model setup and actually add the channels to the simulation objects.

Source

Inside GenerateProduct, the event no longer checks if the queue's input is open. Instead, the event checks if there is no currently produced product waiting to move out of the source. We could also check if the channel is ready to send an entity using ChannelComponent::IsReady (which would be closer to what the code did before), but using channels we can let the source create a product when it wants to and automatically send it when the queue (or another object) is ready to receive it.

So the CurrentProduced variable is added to SourceBehavior, and its value is set inside the if-statements body. Additionally, the event no longer uses UpdateParentOnEntity to send the product directly, but uses ChannelComponent::Open to open the output channel of the source. When the input channel of the receiving object is also open (whether it is already open now, or is opened later), the product is sent through the channel. This is done in the OnOutputChannelReady callback of SourceBehavior. This callback is called every time the channel becomes ready, or when ChannelComponent::Open is called on either the input- or output-channel.

Inside the OnOutputChannelReady callback, the source first checks if the CurrentProduced entity is valid. If it is not, the callback does not do anything else. We do this because the callback might be triggered by the receiving side of the channel. When this happens, the source might not yet have produced a new product, so the source needs to check whether it can even send a product.

When the source does have a product, it first temporarily stores the CurrentProduced entity in toSend before setting it to an invalid entity. This is done like this because when the receiving side of the channel receives the product it may trigger OnOutputChannelReady on the source again. When that happens, the execution of the first call to OnOutputChannelReady is not finished (the one that just sent the product), but OnOutputChannelReady gets called again before the first call can finish executing. The callback will then try to send the same product again, which is not possible.

Finally, the source sends the toSend entity via the ChannelComponent::Send function. This function takes the channel through which the entity is sent as the first parameter, and the entity to send as the second. Lastly, the source closes its output channel.

// Source.cs

internal struct GenerateProductEvent : ILocalEvent<GenerateProductEvent>
{
    public Entity SourceEntity;

    public void OnEvent()
    {
        SubModel subModel = SubModel.Get();
        SourceBehavior source = SourceEntity.GetComponent<SourceBehavior>();

        // Only create a product when it can be moved to the next entity
        // OLD: if (source.Target.GetComponent<QueueBehavior>().InputOpen)
        if (source.CurrentProduced == CEntity.InvalidEntity())
        {
            // Create the product
            Entity entity = subModel.CreateEntity(SourceEntity, $"Product{source.Produced + 1}");
            entity.AddComponent<Product>();
            source.Produced++;
            // NEW
            source.CurrentProduced = entity;

            // Move the product to the next simulation object
            // OLD: subModel.UpdateParentOnEntity(entity, source.Target);
            // NEW
            Entity output = SourceEntity.GetComponent<ResourceComponent>().Value.GetOutputChannel(0);
            ChannelComponent.Open(output);
        }

        // Re-schedule this function, creating a loop
        ulong delay = subModel.ApplyModelPrecision(source.GenerationTime);
        EventScheduler.ScheduleLocalEvent(0, delay, new GenerateProductEvent()
        {
            SourceEntity = SourceEntity
        });
    }
}

internal class SourceBehavior : ScriptBehaviorComponent
{
    // REMOVED: public Entity Target;
    public ulong GenerationTime = 5;
    public ulong Produced = 0;
    public Entity CurrentProduced = CEntity.InvalidEntity(); // NEW

    public override void OnStart()
    {
        // Same as before ...
    }

    public override void OnOutputChannelReady(Entity outputChannel)
    {
        if (CurrentProduced == CEntity.InvalidEntity())
            return;

        /*
         * Store toSend locally first, because CurrentProduced needs to be invalid before Send is called.
         * Otherwise, the receiving side of the channel may trigger this callback again
         * and CurrentProduced will still be valid, thus it would try to send the same entity again.
         */
        Entity toSend = CurrentProduced;
        CurrentProduced = CEntity.InvalidEntity();
        ChannelComponent.Send(outputChannel, toSend);
        ChannelComponent.Close(outputChannel);
    }
}

Queue

Just like in the source, the Target variable is removed too in the queue. Additionally, InputOpen is removed because the channels are now used to check if the queue can receive entities. OnEntered, OnExited, and MoveNext are also removed because their functionality will be replaced by the channel callbacks.

A Queue data structure is added to track the entities in the queue simulation object. Previously, the queue used its child entities to track which entities it has and which to send next. However, the queue now has child entities that are channels, which it should not accidentally send through its output channel. So instead, the queue now uses a Queue to keep track of the product entities it has.

By default all channels are closed. Because the queue needs to be ready to receive any entities when the simulation starts, both its input- and output-channel are opened in OnStart. As you can see, the queue uses its resource component to first acquire the input- and output-channel using the GetInputChannel and GetOutputChannel functions respectively. These functions get channels by their index.

Let's first implement the receiving of entities. Previously, we used the OnEntered callback to receive entities, but we will now use the channel's equivalent: OnReceive. The OnReceive callback is executed whenever the connected entity receives an entity through one of its input channels. When the queue receives an entity, it is first added to queue. Next, the queue attempts to send the entity using TrySend. Lastly, the queue checks if its capacity has been reached. If it is, the input-channel of the queue is closed so it can no longer receive entities.

The TrySend method is similar to the previous MoveNext method. In this method, the queue attempts to send the next entity in queue through its output channel. First, the output channel is retrieved and stored in output. Next, queue checks if the output channel is ready to send entities and whether it even has entities to send. If both are true, the queue dequeues the first entity of queue and sends it through the output-channel. Because the queue just sent an entity, we know the capacity can not have been reached anymore, so the queue's input channel is opened again.

It's not always the case that the queue receives an entity and is immediately able to send it through its output-channel. It should also be possible for the simulation object after the queue to indicate it is able to receive the next entity. This is what the OnOutputChannelReady callback is used for. When the output channel of the queue is ready, this callback is triggered. Inside the callback, the queue first checks if it has any entities to send. If it does not, it returns early. Otherwise, it dequeues the first entity of the internal queue and sends it through the output channel. Lastly, it opens the queue's input channel again because it knows the capacity can not have been reached anymore.

// Queue.cs

internal class QueueBehavior : ScriptBehaviorComponent
{
    // REMOVED: public Entity Target;
    public ulong Capacity = 5;
    // REMOVED: public InputOpen = true;
    private readonly Queue<Entity> queue = new(); // NEW

    // REMOVED: OnEntered, OnExited, MoveNext

    // NEW
    public override void OnStart()
    {
        Ref<ResourceComponent> resource = ConnectedEntity.GetComponent<ResourceComponent>();
        Entity input = resource.Value.GetInputChannel(0);
        Entity output = resource.Value.GetOutputChannel(0);

        ChannelComponent.Open(input);
        ChannelComponent.Open(output);
    }

    // NEW
    public override void OnReceive(Entity inputChannel, Entity child)
    {
        Logger.Debug("Queue received {0}", child.GetName());

        queue.Enqueue(child);
        TrySend();

        if ((ulong)queue.Count >= Capacity)
        {
            Entity input = ConnectedEntity.GetComponent<ResourceComponent>().Value.GetInputChannel(0);
            ChannelComponent.Close(input);
        }
    }

    // NEW
    public override void OnOutputChannelReady(Entity outputChannel)
    {
        // If the queue is empty, do nothing and return
        if (queue.Count == 0)
            return;

        ChannelComponent.Send(outputChannel, queue.Dequeue());

        Entity input = ConnectedEntity.GetComponent<ResourceComponent>().Value.GetInputChannel(0);
        ChannelComponent.Open(input);
    }

    // NEW
    public void TrySend()
    {
        Entity output = ConnectedEntity.GetComponent<ResourceComponent>().Value.GetOutputChannel(0);
        if (ChannelComponent.IsReady(output) && queue.Count > 0)
        {
            ChannelComponent.Send(output, queue.Dequeue());

            Entity input = ConnectedEntity.GetComponent<ResourceComponent>().Value.GetInputChannel(0);
            ChannelComponent.Open(input);
        }
    }
}

Server

In the server, both the From and To entities are removed, and the CurrentlyProcessing variable is added. Just like the queue, the server can no longer rely assuming its children are all products, which is why this variable is added. The OnEntered and OnExited callbacks are also removed, and the OnStart callback opens the channels by default, just like in the queue.

OnReceive sets the value of CurrentlyProcessing and closes the server's input channel, because it can only process one product at a time. Lastly, OnReceive calls the Process function, just like OnEntered used to do.

Inside ServerProcessEvent, we no longer use InputOpen of the SinkBehavior, but check if the output-channel of the server is ready. As you can see, this line of code used to make the server hardcoded to send only to a SinkBehavior, but it is now able to generalize and send to any object that is on the other end of its output channel. If the output-channel is ready, the server temporarily stores the CurrentProcessing entity into a local toSend variable, just like it was done in the source for CurrentProduced. Next, it updates the product's state and sends it through the output-channel. Lastly, it opens the input channel again so the next product can be processed by the server.

// Server.cs

internal struct ServerProcessEvent : ILocalEvent<ServerProcessEvent>
{
    public Entity ServerEntity;

    public void OnEvent()
    {
        // REMOVED: SubModel subModel = SubModel.Get();
        ServerBehavior server = ServerEntity.GetComponent<ServerBehavior>();

        // OLD: if (server.Target.GetComponent<SinkBehavior>().InputOpen)
        // NEW
        Entity output = ServerEntity.GetComponent<ResourceComponent>().Value.GetOutputChannel(0);
        if (ChannelComponent.IsReady(output))
        {
            // Product can be moved, process it and move it

            // OLD
            // Entity child = ServerEntity.GetComponent<RelationComponent>().Value.First;
            // child.GetComponent<Product>().Value.Filled = true;
            // subModel.UpdateParentOnEntity(child, server.Target);

            // NEW
            Entity toSend = server.CurrentlyProcessing;
            server.CurrentlyProcessing = CEntity.InvalidEntity();

            toSend.GetComponent<Product>().Value.Filled = true;
            ChannelComponent.Send(output, toSend);

            Entity input = ServerEntity.GetComponent<ResourceComponent>().Value.GetInputChannel(0);
            ChannelComponent.Open(input);
        }
        else
        {
            // Could not move the product, retry
            server.Process();
        }
    }
}

internal class ServerBehavior : ScriptBehaviorComponent
{
    public double BaseProcessTime = 10.0;
    // REMOVED: public Entity From;
    // REMOVED: public Entity To;
    // REMOVED: public bool InputOpen = true;
    public Entity CurrentlyProcessing = CEntity.InvalidEntity(); // NEW

    // REMOVED: OnEntered, OnExited

    // NEW
    public override void OnStart()
    {
        Ref<ResourceComponent> resource = ConnectedEntity.GetComponent<ResourceComponent>();
        Entity input = resource.Value.GetInputChannel(0);
        Entity output = resource.Value.GetOutputChannel(0);

        ChannelComponent.Open(input);
        ChannelComponent.Open(output);
    }

    // NEW
    public override void OnReceive(Entity inputChannel, Entity child)
    {
        Logger.Debug("Server received {0}", child.GetName());

        CurrentlyProcessing = child;
        Entity input = ConnectedEntity.GetComponent<ResourceComponent>().Value.GetInputChannel(0);
        ChannelComponent.Close(input);
        Process();
    }

    public void Process()
    {
        // Same as before ...
    }
}

Sink

For the sink, we also remove the InputOpen variable and OnEntered callback. OnStart is again used to make sure the channels of the sink are opened by default.

The OnReceive for the sink does exactly the same as OnEntered used to do. For the sink, the input channel does not need to be closed, because this sink is always able to accept products.

// Sink.cs

internal class SinkBehavior : ScriptBehaviorComponent
{
    public ulong Received = 0;
    // REMOVED: public bool InputOpen = true;

    // REMOVED: OnEntered

    // NEW
    public override void OnStart()
    {
        Ref<ResourceComponent> resource = ConnectedEntity.GetComponent<ResourceComponent>();
        Entity input = resource.Value.GetInputChannel(0);
        ChannelComponent.Open(input);
    }

    // NEW
    public override void OnReceive(Entity inputChannel, Entity child)
    {
        Received++;
        SubModel.Get().DestroyEntity(child);
    }
}

Updating the model setup

Now that the simulation objects are updated, we can update the simulation setup. A ResourceComponent is added to each simulation object. Additionally, child entities are created for each object that needs channels. Depending on whether the object only needs one of either or both types of channel, either one or two child entities are created. For example, the source only needs an output channel because it will not receive any products, so it will only get an output channel. The queue on the other hand needs both an input and output, so it gets two children. To add a channel to a child entity, ChannelComponent::AddChannelComponent is used. As its first argument, it takes the entity that becomes the channel entity. As the second, the type of channel is specified, and as the third argument, the parent of the channel (holding the ResourceComponent) is given.

// Program.cs

// Creating the simulation objects
Entity sourceEntity = subModel.CreateEntity("Source");
SourceBehavior source = sourceEntity.AddComponent<SourceBehavior>();
// NEW
sourceEntity.AddComponent<ResourceComponent>();
Entity sourceOutputEntity = subModel.CreateEntity(sourceEntity, "SourceOutput");
ChannelComponent.AddChannelComponent(sourceOutputEntity, ChannelType.Output, sourceEntity);

Entity queueEntity = subModel.CreateEntity("Queue");
queueEntity.AddComponent<QueueBehavior>();
// NEW
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);

Entity serverEntity = subModel.CreateEntity("Server");
serverEntity.AddComponent<ServerBehavior>();
// NEW
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);

Entity sinkEntity = subModel.CreateEntity("Sink");
SinkBehavior sink = sinkEntity.AddComponent<SinkBehavior>();
// NEW
sinkEntity.AddComponent<ResourceComponent>();
Entity sinkInputEntity = subModel.CreateEntity(sinkEntity, "SinkInput");
ChannelComponent.AddChannelComponent(sinkInputEntity, ChannelType.Input, sinkEntity);

Now that each simulation object has channels, they need to be connected. This is done via the ChannelComponent.Connect function. The function requires two arguments: first the output-channel entity, next the input-channel entity.

// Program.cs

// Creating the simulation objects
// ...

// Connect the simulation objects
ChannelComponent.Connect(sourceOutputEntity, queueInputEntity);
ChannelComponent.Connect(queueOutputEntity, serverInputEntity);
ChannelComponent.Connect(serverOutputEntity, sinkInputEntity);

When we run the model again, the result will be the same as before. Except now, the model can be easily reconfigured to have different simulation objects connected to each other thanks to the channels.

[I] Thread [33708] Starting Enterprise Resource Simulator Core
[I] Thread [33708] Version 0.7.0 | Build Date: 2026-04-23 15:13:41
[I] Thread [33708] Reading initial settings file
[I] Thread [33708] [Info] is new LogLevel!
[I] Thread [33708] [Info] is new LogLevel!
[I] Thread [33708] Sink received 361 products
[I] Thread [33708] Waiting for JobSystem to finish remaining tasks, then shut down
[I] Thread [33708] Exiting JobSystem
[I] Thread [33708] Closing Enterprise Resource Simulator Core

Next steps

Was this page helpful?