Unity networking utilizes the Client-Server networking paradigm. This means that there needs to be a single server, which clients connect to. The server handles the routing of network messages to clients. A server can be a remote machine that only handles the messaging, but for a game like Duet, which is going to have a very small number of connected clients, it’s much more likely that one of the player’s devices will act as the server. Creating a server is a relatively simple affair, with the server instance calling Network.InitializeServer. Clients then just use Network.Connect to connect to that server. There is a bit of complexity regarding Network Address Translation (NAT), and accessing remote machines, but dealing with those issues is a bit beyond the scope of this document.
Connecting the server and client(s) initially does very little. In theory, connected servers and clients could be on completely different scenes, with totally different game states. This may seem strange initially, but allows for flexibility and performance that would be impossible if everything was kept in perfect sync across all peers (clients and server). For example, Project Gorgon (an in-development Unity-based MMORPG) uses several “headless” (without a graphical component) Unity applications to handle NPC interactions. Even though the client and the NPC driver are two completely different Unity applications, they are able to share information with each other. If we want to provide a completely synchronized experience with multiple identical clients, then we need to provide a structure that handles that. Fortunately, that isn’t terribly difficult.
When peers are connected, only game objects that have Network Views attached to them can transmit information across the network. Network Views are built-in Unity components, and have a few different operation modes that allow for situation-specific performance tuning. There are three pieces of information that are crucial to utilizing Network Views: the owner GUID, the State Synchronization Mode, and the Observed Component.
The owner GUID is the least visible of the three properties discussed above, primarily because it is initialized by Unity, and cannot easily be changed. Whichever peer creates the Network View is set as the owner. This is important because only the owner can write changes to the state synchronization stream. Other peers can perform local changes, but Unity won’t send those changes to other connected peers.
For example, Client A owns their player object, and can send position changes to the other peers. Client B owns their player object which punches Client A, sending them backwards. The change in position seen on Client B’s instance won’t be seen by any of the other peers. For the force to actually take effect, Client B needs to communicate to Client A that its player was punched, and have Client A react to that change. This doesn’t sound ideal, but it actually allows a technique called Dead Reckoning, where Client B show’s Client A’s reaction to B’s action before A has actually sent a reaction message. For more information, the Valve wiki has an excellent article on interpolation, prediction, and compensation.
Each Network View (there can be multiple Network Views attached to a single GameObject) has an Observed Component and a Synchronization Mode. The Observed Component is the component whose data will be automatically sent through the network by Unity’s networking. Transforms are commonly used, but any type component can be observed. Since only one component can be observed per Network View, if you want to observe multiple components of an object, you’ll need to utilize multiple Network Views. It may be possible to create a single Component that is an aggregate of all of the components you’d like to sync, but I haven’t investigated that possibility.
The Network View’s Synchronization Mode can be either “Off”, “ReliableDeltaCompressed”, or “Unreliable”. Turning the Synchronization Mode Off means that the Observed Component will not be sent over the network, and can be left null. This can seem like a wasted option, but it is the way of using the game object solely as an endpoint for Remote Procedure Calls (RPCs) without sending useless information over the network. Surprisingly, both ReliableDeltaCompressed and Unreliable Synchronization Modes utilize the UDP protocol, which is inherently unreliable. Unity Networking has it’s own network layer that utilizes ACKS and NACKS to make sure that anything marked as ReliableDeltaCompressed is, in fact, reliably streamed. ReliableDeltaCompressed is much slower than the Unreliable Synchronization, but it is guaranteed to have all updates seen by all peers, and that all of the updates are seen in the correct order.
The Unreliable setting, unsurprisingly, makes none of these promises. When using Unreliable Synchronization, messages can be lost or arrive out of order. This isn’t as useless as it sounds. When the component being synchronized is something that is constantly changing, such as a player’s position, there are going to be lots of updates being pushed through the network. If a small percentage of them are lost in the cold void of the internet, it isn’t a very big problem. Any lost messages are likely to be rapidly made outdated by newer ones that do make it through to the peers. Unreliable Synchronization is much faster than ReliableDeltaCompressed, which makes it ideal for data that will be constantly changing.
It’s worth noting again that with all types of Synchronization, only the changes made by the owner peer will be reflected across the network. Even if the server wishes to make changes, if it doesn’t own the object, it cannot. Therefore, if you want the server to be the authority on game state (which is a fairly standard requirement), the server needs to be the owner of all important objects, and have the players that “control” them send requests for state changes.
Observing a Script
Along with observing basic unity Components like Transforms and RigidBodies, a NetworkView can be given a custom MonoBehavior as its Observed Component. When a message comes in (or a request to send is made), the MonoBehavior’s OnSerializeNetworkView method is called. The BitStream parameter can have data pushed to or received by it using the stream’s Serialize function. Determining whether or not the Component is an owner is a simple matter of reading the stream’s isWriting property. When setting up the functions that read and write serialized data, it is important that the order data is serialized and deserialized is identical, otherwise the right data will end up in the wrong place. Only basic types (int, float, etc) can be serialized, along with Quaternions, Vector3s, and two types relating to NetworkPlayers. Custom objects, even if they are serializable, can’t be send through this function, nor can variable-size objects like arrays. In theory, this limitation could be circumvented by sending the length of the array, and then the contents.
Like the built-in synchronization for unity Components, the order and reliability of OnSerializeNetworkView calls depends on the Synchronization Mode of the Network View. In Unreliable mode, the data is sent/received approximately 15 times per second, though that can be changed by changing the Network.sendRate parameter. Rather than send a certain number of times per second, when Synchronization is in ReliableDataCompressed mode, changes are only sent/received when unity detects that a the component has changed in a way that warrants a data update. Since there is more overhead in this mode, it makes sense that the updates should strive to contain more data, but keep the number of transmissions lower.
Since the custom Component is still being Synchronized via the NetworkView, it holds to the restriction that only the owner can transmit data, and all non-owner peers can merely read the stream. Fortunately, there is a way for peers to send messages to each other
Unfortunately, the Unity documentation is relatively light when it comes to discussing the performance of RPCs in real-time games. A note is made that the number of parameters of an RPC shouldn’t be overwhelming, which seems to imply that they are expected to be used for fast, frequent updates. However, RPC calls are guaranteed to be received in the same order that they are sent, which can introduce latency if a particular message is misplaced. Since they can, in theory, be called at any time by any component attached to a NetworkView, it would seem that they fill a space between the slower and reliable ReliableDataCompressed and Unreliable modes of State Synchronization. Their ability to affect other peer’s game objects, though, makes them invaluable. This doesn’t expose any additional risk of hijacked peers, though, since the owners of those objects receive the RPC, and can decide if it is, in fact, a message worth listening to.