Passing events between ECS and UI

I’m probably being pretty dense here, and apologies if this has been asked and answered somewhere else, but how are people dealing with sending events to and from the UI?

The best practice articles and tutorials for UI in Unity that I’ve seen, talk about using ScriptableObjects to hold Actions and then passing that reference between subscribers and publishers in the inspector. However it seems that ECS articles talk about using ECS itself as an event system, and it seems counter intuitive to use ScriptableObjects with ECS (And pretty much a bad idea).

So how are people doing this? Do they use ECS for events, and if so how do you manage notifying the UI of changes? Do you just have the UI use World.DefaultGameObjectInjectionWorld.GetExistingSystem as a service locator and then have systems emit events?

All the ways I think of getting ECS and the UI to play nicely together seem a bit janky, and it would be nice to know what are some recommended ways of doing this.

I’m also really surprised by the lack of documentation in this area, as I would have thought this would be a pretty major use case. It’s completely possible that I’m overlooking something though.

My use case is that I have an InventoryGridController that manages the UI for a grid of items, and this is used in a couple of different places. The code for this is roughly:

public class InventoryGridController: MonoBehaviour
{
    [SerializeField] private ScriptableEvent _onItemClickedEvent = default;
    [SerializeField] private ScriptableEvent _onInventoryChanged = default;

    public void OnEnable()
    {
        if (_onInventoryChanged != null)
        {
            _onInventoryChanged.Event += (_) => Refresh();
        }
    }
...
}

public class ScriptableEvent : ScriptableObject
{
    public event Action<IEventData> Event;

    public void Invoke(IEventData eventData = null)
    {
        Event?.Invoke(eventData);
    }
}

I’ve also now got a Behaviour that deals with receiving the UI events and passing them to the system:

public class BuildEvents : MonoBehaviour
{
    [AssetSelector]
    [SerializeField] private ScriptableEvent _onStartBuilding;
    private BuildSystem _buildSystem;

    public void OnEnable()
    {
        _onStartBuilding.Event += OnStartBuilding;

        _buildSystem = World.DefaultGameObjectInjectionWorld.GetExistingSystem<BuildSystem>();
    }

    private void OnStartBuilding(IEventData obj)
    {
        if (obj is InventoryItemEventData itemEventData)
        {
             _buildSystem.StartBuilding(itemEventData.ItemTypeId);
        }
    }
}

And then I have the following system and behaviour for events travelling from ECS to the UI:

public class InventoryEvents : MonoBehaviour
{
    [AssetSelector]
    [SerializeField] private ScriptableEvent _onInventoryChanged;

    private InventoryEventSystem _inventorySystem;

    public void OnEnable()
    {
        _inventorySystem = World.DefaultGameObjectInjectionWorld.GetExistingSystem<InventoryEventSystem>();

        _inventorySystem.OnInventoryChanged += OnInventoryChanged;
    }

    private void OnInventoryChanged(InventoryEventData eventData)
    {
        _onInventoryChanged.Invoke(eventData);
    }
}
  
[UpdateInGroup(typeof(EventSystemGroup))]
public class InventoryEventSystem : SystemBase
{
    public Action<InventoryEventData> OnInventoryChanged { get; set; }

    protected override void OnUpdate()
    {
        Entities.WithAll<InventoryChanged>().ForEach((Entity entity) => {
            OnInventoryChanged.Invoke(new InventoryEventData { Entity = entity });
            EntityManager.RemoveComponent<InventoryChanged>(entity);
        }).WithStructuralChanges().WithoutBurst().Run();
    }
}

Anything that works is fine, you can’t go wrong with ECS, it just won’t allow you to do so.

In short what I do for UI:

  • Use MonoBehaviours, ScriptableObjects, or Conversion to author new entities (Produce new event);
  • Use single metadata queries to figure out what has changed then throw them out from ECS to MonoBehaviour side;
  • Apply results on MonoBehaviour side.

Alternatively, its possible to just query over directly ComponentDataObjects → MonoBehaviours after you’re done, and call whatever you need. They can be Run() on just fine.

Few suggestions:

  • Use EntityQuery to remove all components in batch instead of iterating over each and removing them. This is way faster. (e.g. can be called after ForEach(…)).
    Both EntityManager and ECB support EntityQuery as a parameter.

  • Don’t run these type of systems in SimulationSystemGroup, because they cause structural changes and those will cause a sync point.
    Personally I place them after EndSimulationEntityCommandBufferSystem to a completely custom group that includes actual custom .Update() of MonoBehaviours. This way most of the jobs can run uninterrupted in SimulationSystemGroup.

  • Code style preference, but you can also apply .WithAll(…) after .ForEach(…).
    Codegen supports that as well. As a result .ForEach block would look nicer.

1 Like

Hey I update the game UI with a MonoBehaviour GameOverlayUpdater.

The key thing to note is the ECS cannot “push” to MonoBehaviours. MonoBehaviours can “drop” data into ECS and then “pick it up” once it has been processed. So MonoBehaviours can create, update, read, delete entities. For updating UI I “listen” for entities that have data I want, and if they have been changed from what I have in my MonoBehaviour, I update the MonoBehaviour data.

It basically checks for entities using queries, and if they exist it checks their components and if they are different then the current values in the UI. If they are different it updates the UI.

using UnityEngine;
using UnityEngine.UIElements;
using Unity.Entities;
using Unity.NetCode;
using Unity.Collections;
public class GameOverlayUpdater : MonoBehaviour
{
    //This is how we will grab access to the UI elements we need to update
    public UIDocument m_GameUIDocument;
    private VisualElement m_GameManagerUIVE;
    private Label m_GameName;
    private Label m_GameIp;
    private Label m_PlayerName;
    private Label m_CurrentScoreText;
    private Label m_HighScoreText;
    private Label m_HighestScoreText;
    //We will need ClientServerInfo to update our VisualElements with appropriate values
    public ClientServerInfo ClientServerInfo;
    private World m_ClientWorld;
    private ClientSimulationSystemGroup m_ClientWorldSimulationSystemGroup;
    //Will check for GameNameComponent
    private EntityQuery m_GameNameComponentQuery;
    private bool gameNameIsSet = false;
    //We need the PlayerScores and HighestScore as well as our NetworkId
    //We are going to set our NetworkId and then query the ghosts for the PlayerScore entity associated with us
    private EntityQuery m_NetworkConnectionEntityQuery;
    private EntityQuery m_PlayerScoresQuery;
    private EntityQuery m_HighestScoreQuery;
    private bool networkIdIsSet = false;
    private int m_NetworkId;
    private Entity ClientPlayerScoreEntity;
    public int m_CurrentScore;
    public int m_HighScore;
    public int m_HighestScore;
    public string m_HighestScoreName;
    void OnEnable()
    {
        //We set the labels that we will need to update
        m_GameManagerUIVE = m_GameUIDocument.rootVisualElement;
        m_GameName = m_GameManagerUIVE.Q<Label>("game-name");
        m_GameIp = m_GameManagerUIVE.Q<Label>("game-ip");
        m_PlayerName = m_GameManagerUIVE.Q<Label>("player-name");
        //Scores will be updated in a future section
        m_CurrentScoreText = m_GameManagerUIVE.Q<Label>("current-score");
        m_HighScoreText = m_GameManagerUIVE.Q<Label>("high-score");
        m_HighestScoreText = m_GameManagerUIVE.Q<Label>("highest-score");
    }
    // Start is called before the first frame update
    void Start()
    {
        //We set the initial client data we already have as part of ClientDataComponent
        m_GameIp.text = ClientServerInfo.ConnectToServerIp;
        m_PlayerName.text = ClientServerInfo.PlayerName;
        //If it is not the client, stop running this script (unnecessary)
        if (!ClientServerInfo.IsClient)
        {
            this.enabled = false;        
        }
        //Now we search for the client world and the client simulation system group
        //so we can communicated with ECS in this MonoBehaviour
        foreach (var world in World.All)
        {
            if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
            {
                m_ClientWorld = world;
                m_ClientWorldSimulationSystemGroup = world.GetExistingSystem<ClientSimulationSystemGroup>();
                m_GameNameComponentQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<GameNameComponent>());
                //Grabbing the queries we need for updating scores
                m_NetworkConnectionEntityQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>());
                m_PlayerScoresQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<PlayerScoreComponent>());
                m_HighestScoreQuery = world.EntityManager.CreateEntityQuery(ComponentType.ReadOnly<HighestScoreComponent>());
            }
        }
    }
    // Update is called once per frame
    void Update()
    {
        //We do not need to continue if we do not have a GameNameComponent yet
        if(m_GameNameComponentQuery.IsEmptyIgnoreFilter)
            return;
        //If we have a GameNameComponent we need to update ClientServerInfo and then our UI
        //We only need to do this once so we have a boolean flag to prevent this from being ran more than once
        if(!gameNameIsSet)
        {
                ClientServerInfo.GameName = m_ClientWorldSimulationSystemGroup.GetSingleton<GameNameComponent>().GameName.ToString();
                m_GameName.text = ClientServerInfo.GameName;
                gameNameIsSet = true;
        }
        //Now we will handle updating scoring
        //We check if the scoring entities exist, otherwise why bother
        if(m_NetworkConnectionEntityQuery.IsEmptyIgnoreFilter || m_PlayerScoresQuery.IsEmptyIgnoreFilter || m_HighestScoreQuery.IsEmptyIgnoreFilter)
            return;
        //We set our NetworkId once
        if(!networkIdIsSet)
        {
            m_NetworkId = m_ClientWorldSimulationSystemGroup.GetSingleton<NetworkIdComponent>().Value;
            networkIdIsSet = true;
        }
        //Grab PlayerScore entities
        var playerScoresNative = m_PlayerScoresQuery.ToEntityArray(Allocator.TempJob);
        //For each entity find the entity with a matching NetworkId
        for (int j = 0; j < playerScoresNative.Length; j++)
        {
            //Grab the NetworkId of the PlayerScore entity
            var netId = m_ClientWorldSimulationSystemGroup.GetComponentDataFromEntity<PlayerScoreComponent>(true)[playerScoresNative[j]].networkId;
            //Check if it matches our NetworkId that we set
            if(netId == m_NetworkId)
            {
                //If it matches set our ClientPlayerScoreEntity
                ClientPlayerScoreEntity = playerScoresNative[j];
            }
        }
        //No need for this anymore
        playerScoresNative.Dispose();
        //Every Update() we get grab the PlayerScoreComponent from our set Entity and check it out with current values
        var playerScoreComponent = m_ClientWorldSimulationSystemGroup.GetComponentDataFromEntity<PlayerScoreComponent>(true)[ClientPlayerScoreEntity];
        //Check if current is different and update to ghost value
        if(m_CurrentScore != playerScoreComponent.currentScore)
        {
            //If it is make it match the ghost value
            m_CurrentScore = playerScoreComponent.currentScore;
            UpdateCurrentScore();
        }
        //Check if current is different and update to ghost value
        if(m_HighScore != playerScoreComponent.highScore)
        {
            //If it is make it match the ghost value
            m_HighScore = playerScoreComponent.highScore;
            UpdateHighScore();
        }           
        //We grab our HighestScoreComponent
        var highestScoreNative = m_HighestScoreQuery.ToComponentDataArray<HighestScoreComponent>(Allocator.TempJob);
        //We check if its current  value is different than ghost value
        if(highestScoreNative[0].highestScore != m_HighestScore)
        {
            //If it is make it match the ghost value
            m_HighestScore = highestScoreNative[0].highestScore;
            m_HighestScoreName = highestScoreNative[0].playerName.ToString();
            UpdateHighestScore();
        }
        highestScoreNative.Dispose();
    }
    void UpdateCurrentScore()
    {
        m_CurrentScoreText.text = m_CurrentScore.ToString();
    }
    void UpdateHighScore()
    {
        m_HighScoreText.text = m_HighScore.ToString();
    }
    void UpdateHighestScore()
    {
        m_HighestScoreText.text = m_HighestScoreName.ToString() + " - " + m_HighestScore.ToString();
    }
}

Source is a how-to for linking UI and ECS: https://moetsi.gitbook.io/unity-dots-multiplayer-xr-sample-project/multiplayer/current-high-and-highest-scores#client-updating-their-game-ui

1 Like