Basic Networking and You

You should already be familiar with the Client/Shared/Server paradigm that Robust uses. If not, you should read up on the previous documentation.

SS14 is a multiplayer game! This is a very important fact, and its a fact you’ll likely have to reckon with quite a lot when coding. Thinking through this properly is important to ensure that things go smoothly and there are no potential security oversights.

Most of the time, networking will involve the server sending some important data to the client, so that the client can do things with it, like show a user interface or show appearance visuals. This is known as replication, and in SS14 is primarily handled through component states.

Component States

A component state is simple–its just a data class inheriting ComponentState. They define which data is sent to the client, and are built up from data within components.

How does the game know when to send this data? Obviously, it doesn’t just send it constantly–that’s hugely unnecessary and would be terrible for performance and bandwidth. Instead, a server system must call Dirty(EntityUid uid, Component component), which marks the entity as ‘dirty’, meaning that it will create and send a new component state for it next tick.

Two special events exist for putting data into and getting data out of component states: ComponentGetState and ComponentHandleState. GetState is always called on the server, and HandleState is called on the client. However, both event subscriptions can go in Shared, and it will still work as expected!

Auto-Component State Generation

Robust Toolbox supports using source generators to massively simplify component state networking. This is vastly preferred to trying to do it manually in most situations. This works by using a C# feature to analyze code before it is compiled, and to automatically generate boilerplate source code.

First, your component, and all of the networked fields, should be in Content.Shared, and the component class should be marked with [NetworkedComponent], which enables networking in the first place.

To use the source generator to automatically replicate fields, make your component class partial, annotate it with [AutoGenerateComponentState], and mark any fields you want to be networked with [AutoNetworkedField]. Then, when you dirty the component (or when it’s first added to an entity), it should Just Work™️ and the client will have all networked fields.

If you have code in the handle state that calls some function after the field setting has been done (such as updating appearance), change the component state attribute to [AutoGenerateComponentState(true)] , and then you can subscribe by-ref to AfterAutoHandleStateEvent and do things in there!

If your field requires cloning for prediction purposes (such as a dict), you can change the field attribute to [AutoNetworkedField(true)]. If you need more complex networking, the manual method should be used.

An example of all of the networking code required for IDCardComponent now, from https://github.com/space-wizards/space-station-14/pull/14845:

// IDCardComponent.cs
[RegisterComponent, NetworkedComponent]
[AutoGenerateComponentState]
public sealed partial class IdCardComponent : Component
{
    [DataField]
    [AutoNetworkedField]
    public string? FullName;

    [DataField]
    [AutoNetworkedField]
    public string? JobTitle;
}

Manual Component State Handling

Sometimes the manual method has to be used, if the handling is more complicated than just simple setting of fields.

Let’s take ambient sounds as an example (although in this instance it could easily be autogenerated):

// AmbientSoundComponent.cs
    [Serializable, NetSerializable]
    public sealed class AmbientSoundComponentState : ComponentState
    {
        public bool Enabled { get; init; }
        public float Range { get; init; }
        public float Volume { get; init; }
    }

This is a definition of a fairly simple component state. It’s marked as [Serializable, NetSerializable], which is required for any object being sent over the network. This class defines three variables that it wants to sync to the client–whether this sound is enabled, its range, and its volume.

Let’s see how this state is constructed on the server:

/// SharedAmbientSoundSystem.cs
        public override void Initialize()
        {
            base.Initialize();
            SubscribeLocalEvent<AmbientSoundComponent, ComponentGetState>(GetCompState);
            SubscribeLocalEvent<AmbientSoundComponent, ComponentHandleState>(HandleCompState);
        }

        ...

// In the event handlers..
        private void GetCompState(Entity<AmbientSoundComponent> ent, ref ComponentGetState args)
        {
            args.State = new AmbientSoundComponentState
            {
                Enabled = ent.Comp.Enabled,
                Range = ent.Comp.Range,
                Volume = ent.Comp.Volume,
            };
        }

One important thing to note is that, in the event handler args, the syntax used is ref ComponentGetState args rather than simply ComponentGetState args. This is required for certain events, as they are value-types raised ‘by-ref’, rather than just classes inheriting EntityEventArgs. This is done for performance reasons and isn’t super important, but its good to note as it can slip you up with runtime errors that may be confusing if you forget ref.

To specify the state to send, you simply set the State field on the event with your new component state, constructed from the values on the server component. Easy!


And on the client (technically still in shared, but this code is only run on the client!)

/// SharedAmbientSoundSystem.cs
        ...

        private void HandleCompState(Entity<AmbientSoundComponent> ent, ref ComponentHandleState args)
        {
            if (args.Current is not AmbientSoundComponentState state)
                return;

            ent.Comp.Enabled = state.Enabled;
            ent.Comp.Range = state.Range;
            ent.Comp.Volume = state.Volume;
        }

Again, note the ref.

The first line of the method just does some fancy C# Pattern Matching to cast the Current field on the event args into the state that we’re looking for, since this is a pretty generic event.

From then on, the client simply uses the values contained in the component state to sync its component, so that any client-specific ambient code (like.. you know.. playing the sounds) can run as the server intends.

Component Networking Example

As a high-level example, let’s see how atmospheric vents handle their ambient sounds.

/// GasVentPumpSystem.cs
            ...
            _ambientSoundSystem.SetAmbience(uid, true);
            if (!vent.Enabled)
            {
                _ambientSoundSystem.SetAmbience(uid, false);
            ...

The vent sets its ambience to true first, as a default. However, if the vent is not enabled, it will disable ambience.

This is all in server code, though! In the SetAmbience function, the ambience system calls Dirty on the entity, which tells the server that this entities data has been updated and the client needs to be made aware of that. Then, next tick, the server raises a ComponentGetState event on the vent, and the ambient sound state is created and sent.

Once the client receives it (after latency), it will raise ComponentHandleState on the vent, which then causes the ambient sound to be properly disabled on the client. Neat!

Network Events

The other main way for the server and client to communicate, besides replication through component states, and that’s network events and the lower-level NetMessage.

Network events are as opposed to local events (RaiseLocalEvent or SubscribeLocalEvent ring a bell?), which are exclusively ‘local’ to the side of the network they were raised on, whereas network events are exclusively sent over the network. Network events use the equivalent RaiseNetworkEvent and SubscribeNetworkEvent.

Network events contain arbitrary data, not tied to any component or entity in specific (which means they can’t be directed), and can be sent at any time, from either the client or the server. When handling network event sent from the client on the server, you should obviously exercise caution and treat it as untrustworthy, since hackers can always send whatever data they please.

NetMessage is the low-level equivalent to network events (in fact, network events just create a NetMessage themselves). You should avoid using them unless you know what you’re doing, so I won’t cover them here besides mentioning them.

Example

Let’s look at adminhelps (also called the bwoink system) and see how that sends arbitrary non-entity-specific data to clients.

Here’s how the network event is defined:

/// SharedBwoinkSystem.cs

...
    
        [Serializable, NetSerializable]
        public sealed class BwoinkTextMessage : EntityEventArgs
        {
            public DateTime SentAt { get; }
            public NetUserId ChannelId { get; }
            public NetUserId TrueSender { get; }
            public string Text { get; }

            public BwoinkTextMessage(NetUserId channelId, NetUserId trueSender, string text, DateTime? sentAt = default)
            {
                SentAt = sentAt ?? DateTime.Now;
                ChannelId = channelId;
                TrueSender = trueSender;
                Text = text;
            }
        }

Note that this is basically identical to a normal event data class–except, that its marked as NetSerializable, for the same reasons mentioned above for component states. I won’t go over the specific data here–I imagine you get the idea.

The interesting thing about this event is that its raised and handled on both the client and server. So, we’ll look at those separately.

Client to Server

/// Content.Client ... BwoinkSystem.cs
...
        public void Send(NetUserId channelId, string text)
        {
            // Reuse the channel ID as the 'true sender'.
            // Server will ignore this and if someone makes it not ignore this (which is bad, allows impersonation!!!), that will help.
            RaiseNetworkEvent(new BwoinkTextMessage(channelId, channelId, text));
        }

Send here is called whenever the BWOINK (tm) UI input text is entered on the client:

/// BwoinkPanel.xaml.cs
...
        private void Input_OnTextEntered(LineEdit.LineEditEventArgs args)
        {
            if (string.IsNullOrWhiteSpace(args.Text))
                return;

            _bwoinkSystem.Send(ChannelId, args.Text);
            SenderLineEdit.Clear();
        }
...

Easy enough! The client types a message, presses enter, then the Bwoink system creates a network event out of its message and sends it to the server. Let’s see how its handled on the server:

Server Handling

Okay the handler for this is a little big so I’ll trim it down to the important bits:

/// Content.Server ... BwoinkSystem.cs

        // ok this is technically in shared and overriden on server/client but you get the idea for simplicity..
        public override void Initialize()
        {
            base.Initialize();

            SubscribeNetworkEvent<BwoinkTextMessage>(OnBwoinkTextMessage);
        }

        ...

        protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs)
        {
            base.OnBwoinkTextMessage(message, eventArgs);
            var senderSession = (IPlayerSession) eventArgs.SenderSession;

            // TODO: Sanitize text?
            // Confirm that this person is actually allowed to send a message here.
            var personalChannel = senderSession.UserId == message.ChannelId;
            var senderAdmin = _adminManager.GetAdminData(senderSession);
            var authorized = personalChannel || senderAdmin != null;
            if (!authorized)
            {
                // Unauthorized bwoink (log?)
                return;
            }

            var escapedText = FormattedMessage.EscapeText(message.Text);

            var bwoinkText = ...

            var msg = new BwoinkTextMessage(message.ChannelId, senderSession.UserId, bwoinkText);

            ...
            
            // Admins
            var targets = _adminManager.ActiveAdmins.Select(p => p.ConnectedClient).ToList();

            // And involved player
            if (_playerManager.TryGetSessionById(message.ChannelId, out var session))
                if (!targets.Contains(session.ConnectedClient))
                    targets.Add(session.ConnectedClient);

            foreach (var channel in targets)
                RaiseNetworkEvent(msg, channel);
            
            ...

One thing you’ll notice is the function signature here–network events arent entity-specific, again, so the only two args are the event itself, and the session that sent it (if this event is coming from the client!)

The first thing this handler does is the most important part–it figures out if the client is actually allowed to send this ahelp message! It does this by verifying that the player is actually in an active ticket, or that they’re an admin. Simple enough. Always verify events sent from the client!

The next thing it does is retransmit this message to various different parties, so that they can see it too. It first gets all active admins, then gets the active player in the current ticket, and sends the same message back to them.

Client Handling

Let’s go back to the client to see what it does with this new text message sent from the server.

/// Content.Client ... BwoinkSystem.cs
        // ok this is technically in shared and overriden on server/client but you get the idea for simplicity..
        public override void Initialize()
        {
            base.Initialize();

            SubscribeNetworkEvent<BwoinkTextMessage>(OnBwoinkTextMessage);
        }

        ...

        protected override void OnBwoinkTextMessage(BwoinkTextMessage message, EntitySessionEventArgs eventArgs)
        {
            base.OnBwoinkTextMessage(message, eventArgs);
            LogBwoink(message);
            // Actual line
            var window = EnsurePanel(message.ChannelId);
            window.ReceiveLine(message);
            // Play a sound if we didn't send it
            var localPlayer = _playerManager.LocalPlayer;
            if (localPlayer?.UserId != message.TrueSender)
            {
                SoundSystem.Play(Filter.Local(), "/Audio/Effects/adminhelp.ogg");
                _clyde.RequestWindowAttention();
            }

            _adminWindow?.OnBwoink(message.ChannelId);
        }

This ones easy to get, so I haven’t trimmed it down at all. It first opens the ahelp window for the user, sends the line to the window so it can be visualized, and then plays the Funny Sound (if they didn’t send it). No validation is done here (more or less), because we can trust the server to send accurate data to the client.

Potentially Visible Set (PVS)

You’ve likely heard this term bandied about a bit if you’ve looked in development, as its a pretty big deal.

Think for a second–there’s a lot of god damn entities in this game! And most likely, a lot of them are calling Dirty constantly, and there’s going to be a lot of clients too. How do we figure out how to send these states to each client? The answer is with the Potentially Visible Set system, or PVS.

This isn’t going to be a super low-level overview of it or anything, but basically, PVS is based on chunks, and only sends component states to clients that are in chunk range of the entity. This is done for two main reasons:

  1. It reduces bandwidth by a lot, by not sending component states to clients that can’t even see the entity in question.
  2. It reduces the possibility for cheating, since hackers physically don’t receive any data about entities too far away from them.

It’s quite slow, but it’s multithreaded and miles faster than the BYOND equivalent–fast enough to get us >250 players on 20 ticks-per-second, so it’s good enough.

As for what this means to you and your code, note that in testing you won’t receive states for entities that are too far away, and you may need to think about the special case of what happens when entities go in and out of range, though you don’t usually need to worry about it.

Subpages