Event Bus

The event bus is a design pattern, it serves the purpose of a "global event system" as it can be invoked from anyone, anywhere and can be listened by anyone, anywhere.

The implementation we use was created by the CS Framework and we use it to create new event buses. To understand further on the design pattern itself, you can read this article.

We use these events when we want something that will be used in more than one place, without having to care about object relation.

We usually want events to be fired by systems when we want to inform other objects something has happened, like a round timer being updated, a player that spawned, an object that was picked up.

Creating

To create a new event, we use partial structs that inherit from the IEvent interface. The reason for the partial keyword is that the CS Framework uses the Roslyn code generation tools to create the rest of the necessary logic on that struct.

public partial struct RoundTimerUpdatedEvent : IEvent 
{
    public int TimerSeconds;
}

It is also interesting and kind-of but not obligatory standard to create constructors for these events, they can become quite large lines at times.

public partial struct RoundTimerUpdatedEvent : IEvent 
{
    public int TimerSeconds;
    
    public RoundTimerUpdatedEvent(int timerSeconds) 
    {
        TimerSeconds = timerSeconds;
    }
}

Pretty straight-forward isn't it? Well, now for the usage.

Invoking

Invoking is the easiest part. You just need to know what you want to invoke and when. The code below creates a new instance of our event, using the constructor we just defined and then invokes it. We also have to specify who called that event, in our case we use the this keyword to say that the class's instance that called it.

All event invoking should happen after OnAwake.

RoundTimerUpdatedEvent event = new RoundTimerUpdatedEvent(30);
event.Invoke(this);
Listening

Listening is the most complex of them, because it contains scary words and more complex management.

It is advised that you add event listeners on OnAwake, to avoid initialization issues.

protected override void OnAwake() 
{
    base.OnAwake();
    
    RoundTimerUpdated.AddListener(HandleRoundTimerUpdated);
}

base.Awake() calls the parent class's Awake function. Then we add the method that will be called when the event is invoked. We'll declare the method as follows:

private void HandleRoundStateUpdated(ref EventContext ctx, in RoundStateUpdated e) 
{
    Debug.Log(e.TimerSeconds);
}

The EventContext is usually unused but it gives us information about the invoking itself. Then we have the event we created, it is the struct that we previously created.

The ref and in keywords are there for optimization reasons but they are required, so always add them if your IDE doesn't autocomplete correctly.

One thing to note here is that you have to remove the listeners eventually, you have to do it on the OnDestroyed callback, but if you're using any class that inherits from Actor or NetworkActor, it already unsubscribes from it if you use the AddHandle method when listening to the events.

protected override void OnAwake() 
{
    base.OnAwake();
    
    AddHandle(RoundTimerUpdated.AddListener(HandleRoundTimerUpdated));
}

Examples

One thing we have to keep in mind is that, with networking, we have to use a data-driven design, as we cannot depend on one-time of events only, a user has to be able to disconnect and reconnect back later with no issues, if we did not use data-driven, the user would lose that information.

So, we can have the best of both worlds, FishNet already has callbacks when SyncVars are changed, and they are changed to the correct value when the user enters the server.

In the ReadyPlayersSystem, we have:

[SyncObject] private readonly SyncList<string> _readyPlayers = new();

This is a SyncList, it updates automatically for all clients, and it as a OnChange callback. It makes us able to do this:

_readyPlayers.OnChange += HandleReadyPlayersChanged;

When the list is changed, we call the HandleReadyPlayersChanged method.

private void HandleReadyPlayersChanged(SyncListOperation op, int index, string s, string newItem1, bool asServer)
{
    SyncReadyPlayers();
}

The method then calls the SyncReadyPlayersMethod, calling, then, our event.

private void SyncReadyPlayers()
{
    ReadyPlayersChanged readyPlayersChanged = new(_readyPlayers.ToList());
    readyPlayersChanged.Invoke(this);
}

All this trajectory is to ensure our client doesn't lose anything when he rejoins a game and can load stuff back correctly, not to mention this is way easier than doing RPCs all over the code.

Anything in the codebase can listen to that event, in that case we use for updating the UI, that changes the player name's color if they are ready or not.

Last updated