This Unity Package can be downloaded here (updated):
For most developers, Unity truly goes out of its way to make things as easy as possible for game development. For example, switching your camera type from perspective to orthographic takes just a click of a button. Switching lights from spot, to directional, to area; again, just a click of a button. However, there are a few parts of Unity that, surprisingly, have no options whatsoever. One of these parts is Unity's networking design. Unity is stubbornly Client-Server in its networking design. There's no drop-down box where you can switch it over to peer-to-peer networking - this is left as an exercise for the programmer.
A peer-to-peer system, though, can be developed on top of Unity's existing client server networking framework by adding additional components that will allow the server to migrate among all networked clients. Minimally, we'll need to build 3 components: Shared State, Server Discovery and Server Migration. In my previous article, I described a simple FPS Networking Sample that could maintain state across all clients using a single NetworkView on each client. An additional advantage to this system is that each client maintained the same information as the "server." Because of this, the Shared State requirement has already been met. The clients are not sharing a lot of information (just the list of players and their locations and targets)... but for now, that's sufficient.
In this article, I'll tackle Server Discovery and finally demonstrate Server Migration in a follow-up article. I'm going to review the most important parts of this solution, so if you haven't already grabbed the Unity package above, you should do so now so you can follow along. Let's start with a process flow diagram...
One of my design goals for this solution was to make it so that a client could easily switch over to become a server at any time. Until we put in the last Server Migration piece, this is done manually by following these steps:
- Start a single-player game
- Hit Escape
- Select "Open to LAN"
Now your game will be available to others, but most importantly you are able to start a single player game without connecting to a server first. If you look at a lot of other Unity Networking tutorials, this isn't even possible. A lot of other tutorials will use Network.Instantiate(), or else make it so that the client only instantiates a player upon the specific direction of the server. So, how do we both instantiate at the direction of the server while not actually being connected to a sever?
Easy. We fake it. In the diagram above (highlighted in blue), FakeServerJoin() is always executed in any single player game. This function will start a local server (which won't actually be used at this point) and then use that local Unity server instance to allocate its own network view. This is okay to do because (if we become a server later) any clients that connect to us will be drawing network views from the same view ID pool -- so there's no duplication of view IDs.
Also, notice how we create a new NetworkPlayer instance, just by calling its default constructor. Normally, your NetworkPlayer instance is something that is created and populated "behind the scenes" for OnPlayerConnected(). Because we're not actually connecting to a server here, OnPlayerConnected() will never get called. But that's okay because NetworkPlayer's default constructor will create "good enough" default values that won't interfere with any real network players that may connect to us later. We just allocate a newtwork view, create a default NetworkPlayer instance, send it all along to the JoinPlayer() function, and pretend these aren't the droids we're looking for... move along... move along.
At any point now, we can open the game to the LAN and people can connect to us normally.
So, now let's see what happens when we open the game to the LAN -- this is where the real Server Discovery functions come into play. You may have noticed that Unity doesn't have a "Network.Broadcast()" function -- and why would it? It strictly adheres to a client-server networking model, after all. It assumes that the client MUST know the IP address of the server. So, how do we communicate with a server when we don't know its IP address (or even if there is a server out there to talk to)?
To accomplish that, the ListenServer() function (highlighted in red) has to do some lower-level networking work. It has to create its own IPEndPoint, create a UdpClient, and create a UdpState. These can all be done with just a single statement each -- so, it's not exactly re-inventing the wheel.
However, there are a couple things we need to be careful with. First and foremost, we must use a random port every time this function is called. In a given application session, you cannot create 2 UdPClients with the same IP address and same port. Unity will complain about this and generate a runtime error. You would see this error on the client if the client connected, dropped, and then tried to rejoin without restarting the application. The client shouldn't have to restart the application to re-join, so we have to solve for this limitation.
Update: I found out that you could actually use 0 for the port number as long as you preset the minPort and maxPort. This might be a slight improvement as (supposedly) the same port will not be chosen if port 0 is used repeatedly. If you are feeling adventurous, you may want to try that.
To get around this, we randomize the port on which we're going to listen for server broadcast responses. Because the server doesn't know what port we're listening on, we have to tell it what that port number is -- we do this by sending our listening port number on the server's known port (which it previously opened for listening for client broadcasts).
Let's summarize before going further:
- Server open client listener on Known Port (15000)
- Client opens a listener on a Random Port (15001 to 15999)
- Client sends on Known Port to the server its chosen Random Port
- Server responds to client on the client's chosen Random Port
Also notice that we are using a lot of asynchronous callbacks. This is done both in the ListenServer() function and in the ListenForClients() function (highlighted in yellow). This will (effectively) create a separate thread on which we can react when the conditions for the callback are met. This is great for a client because it allows the client to still play locally while hosting a game and while the listener thread is independently listening for client join requests :)
It also completely sidesteps the whole "Connect is Blocking" problem (which we're not even going to talk about here -- that would be a whole separate article). The BeginReceive(new AsyncCallback(ListenServerCallback), us1) function will call the function "ListenServerCallback" when a response is received back on our client's chosen Randomly Port number and operate completely on a separate thread. For the server, the same thing happens with the BeginReceive(new AsyncCallback(ListenForClientsCallback), us1) function, but this time it calls the ListenForClients function when a client sends a message on the Known Port.
Note as well that just opening a listening port won't (of course) cause a server to respond -- so, we have to actually send a message to the server. This is done with the FindServer() function. We could send a message to the server something like, "Looking for Server Discovery server," but that would be a waste. The server needs to know the chosen Random Port on which we're listening for responses, so we'll send that instead. Notice too that we specifically have to turn on the capability for the UdpClient to broadcast with "uc2.EnableBroadcast = true;" -- it's off by default.
The last point I wanted to discuss here is the need for a Thread.Sleep() function call. This is needed so that those separate threads that have been started can get a chance to get some processing time. This must be done any time an asynchronous function is used, so it's just easiest to put this in the networkController's Update() function. The integer it accepts is the number of milliseconds to block the current thread -- thereby allowing the listeners some time to check their buffers and execute the callbacks if any messages have been received.
So, that's pretty much all there is to it. Adding Server Discovery to Unity. We're now two-thirds of the way done to having a full peer-to-peer Unity client.
If you liked this article, please follow me on Twitter @Tulrath to know first when the final part of this series is published.
As always, thanks for reading!