Trending
Opinion: How will Project 2025 impact game developers?
The Heritage Foundation's manifesto for the possible next administration could do great harm to many, including large portions of the game development community.
Featured Blog | This community-written post highlights the best of what the game industry has to offer. Read more like it on the Game Developer Blogs or learn how to Submit Your Own Blog Post
This is the third article in my 3-part series demonstrating how Unity's built-in networking can be easily enhanced to allow for a peer-to-peer networking model. In this article, I demonstrate Server Migration - unity package included.
The Unity package for this post can be downloaded here.
Sometimes in game development, it really is best to simply "build your own," iterating out from core functionality to final product. This is especially true when you are trying to accommodate a game mechanic that is not served well by existing code bases. Other times, a game engine or a piece of middleware will do everything you need it to do, and your only job is to simply integrate it correctly. Unity Networking can be both these things - depending on what you are trying to accomplish. I have heard the complaints about Unity's networking model (and probably lodged quite a few myself); but I would also like to show that with a little extra work, you can extend the usefulness of Unity's built-in networking to a much wider range of projects.
As you probably know by now, I have been writing a series of articles that demonstrate how Unity's built-in networking can be used for peer-to-peer networking. In my first article, I demonstrated how a single NetworkView could be used for each client (instead of 1 NetworkView per client and per player). In my second article, I demonstrated how Server Discovery could be added to this as well. Building on this foundation, this third article will demonstrate how Server Migration can be achieved.
For those of you unfamiliar with Server Migration... the simplest explanation is that it simply allows your multiplayer game to keep running even when the server has left the game. One way to accomplish this (and the route we took) is by seamlessly changing the role of a chosen client to the role of server, having all other clients re-connect, and then the game continues.
Server Migration must accomplish two goals:
It must use a server-choosing scheme that will result in the same server being chosen by all clients simultaneously. That way each client knows whether it will continue to be a client or if its role has changed to the server.
It must guarantee that all clients have (or have access to) the same data that the server is using at any time to maintain game state should the server fail.
If you haven't grabbed the Unity package yet for this article yet, go grab it from here, open up the NetworkController and let's walk through some of the more interesting points.
Let's also use this process flow diagram to make things a little easier to follow:
Let's start with a brief overview of what's happening here. The Server Migration project is built on top of the Server Discovery project, so the Server Discovery pieces are not shown in the diagram above. We'll assume, though, that the client has found a server and successfully connected. We'll explain The Happy Path (left-hand side), and The Unhappy Path (right-hand side).
Most of the time, the client (and server/client) is happily running through its Update() loop. During that loop, though, we need to check to see if the game state has entered one of three special states: ServerStarting, ServerReconnecting or ServerAdding.
ServerStarting happens when the server has disconnected and the local client has determined that it will be the new server.
ServerReconnecting happens when the server has disconnected and the local client has determined that it will not be the new server (it will stay in the client role).
ServerAdding happens when the client has first connected to a server, and it is waiting a short amount of time before requesting the complete players list.
Each of these states includes a brief delay before they do their work and "back out" from that special state. Technically speaking, it would be more robust to add an additional conversation between the client and server to let the server control when this transition happens. I decided to leave those out, however, just to make it easier to see what's going on -- a simple time delay is sufficient for our needs. Those cases follow this general pattern:
case((int)State.serverstarting): if(Time.realtimeSinceStartup > exitStateTime){ statecontrol.PopState(); StartServer(); ListenForClients(statecontrol.GetLastGameName()); } break;
As you can see from the diagram, before "backing out" of these special states, the client performs a different set of actions for each state. The PopState() function is useful for sending us back to "wherever we were" in the game because the special states can be entered at any time -- even during states the represent GUI menu states. After all, if the server is going to fail, it doesn't care if you are in the menu or not.
Now that we've reviewed the management of these special states -- let's look at the right-hand side of the diagram which shows how those states are entered in the first place. Before we do that, though, there are two subjects that we must cover...
From my testing, it appears as though the message dispatcher in Unity is completely independent of the Update() function loop. In other words, they are asynchronous, or least neither relies upon execution of the other. Don't get me wrong, I'm not saying this is a Bad Thing. However, we must take this into consideration because that means that it is extremely easy to get into a situation where we are generating an OnDisconnectedFromServer() over and over again -- with no recourse for an Update() call to get out of it. This can easily create an infinite loop. Infinite loops are bad.
The same could be said for almost any built-in Unity message function -- there is no guarantee that an Update() will get called in-between any two messages -- or even get called at all. Because of this, we need a way to "put a lock" on the messages so that we can deal with them and then prevent them from being called again until we specifically "unlock" them. Those checks looks like this:
int cs = statecontrol.GetState(); if((cs==(int)State.serverstarting) | (cs==(int)State.serverreconnecting)) return;
For this project, I only need this lock for the OnDisconnectedFromServer() message. When this message is caught by this function, it guarantees that it will always push ServerStarting or ServerReconnecting onto the state stack. I then check for either of these states at the top of the OnDisconnectedFromServer() function and make and early exit if that is the state we are in. The Update() function will see these states, and after a short delay, will do the work required to exit those states and finally pop the states off the stack.
I really hope that someone is able to prove me wrong, about this -- because it would sure be convenient to use the NetworkPlayer struct ("NPS" for short) to pass-around client data. All my testing, however, can only lead me to one conclusion -- Unity is hard-coded to prevent sending an NPS to a "third party." In other words, the client and the server for any given connection can freely pass that client or that server's NPS back and forth -- but one client's NPS can't be sent to another client. Client A can never receive the NPS of client B.
If you think about it, this is a Good Thing because you probably don't want to expose both your internal (NAT) and external (WAN) IP addresses and ports to a complete stranger (ie. another player in a game that you don't even know). I can see how maybe Unity did this on purpose -- but it seems odd that they'd go out of their way to do so. Perhaps RakNet did it for them though :)
In any case, it doesn't really have that much effect on us -- we'll just send the information we need to send "manually." This amounts to little more than adding a couple of additional attributes to the JoinPlayer() signature and makes the code more self-documenting anyways :p
[RPC] void JoinPlayer(string ip, int p, string g, Vector3 pos, NetworkViewID nv){ ... }
OK... now that we've got those two discussion behind us, all we really need to cover is the function that is used on all clients to come to the same conclusion about who should be the next server. It's easy, actually -- which just pick the client with the lowest GUID value. Because all clients know the GUIDs of all other clients, they will all come to the same conclusion. It also gives us a really good excuse for using LINQ -- everyone wins:
chosenServer = players.Aggregate((c, d) => Convert.ToUInt64(c.guid) < Convert.ToUInt64(d.guid) ? c : d);
So, there we have it. When the server disconnects, each client will independently come to the same conclusion about who will be the next server. The client that has the lowest GUID value will start up a new server and start listening for connections. The other clients will wait a small delay and then connect to the new server. The new server will automatically re-associate the connecting clients with their existing game objects at their current locations, and the game continues :)
I hope you enjoyed this article, as always thanks for reading!
You May Also Like