In this article, we’ll learn to create a multiplayer server process from the previous article and connect to it. We’ll build a multiplayer server out of our Unity game project and add script code to start and handle multiplayer events, like players joining and leaving. Then we’ll build the project as a GUI-free server-only executable. We’ll finish this article by updating our project to act as a client app and connect to the server.
Let’s continue with our Unity game project from the previous article and turn it into server code that our game can connect to for playing multiplayer matches.
Requirements
To follow along with this tutorial, you’ll need a PlayFab account and the following software:
Mission Briefing
Before we begin, let’s go over what we’ll do with the Unity project. This will ensure that the following steps are clear, and it’s easier to understand how we can apply many of the same steps to any other Unity project to add PlayFab features.
In this article, we’ll use the same Unity project to build two executables based on a project setting, a backend server that runs without graphics and can host the multiplayer game sessions, and the client game that each player can use to connect to the server program. We’ll implement an elementary multiplayer server game logic that doesn’t consider network lag or cheating prevention. This code isn’t for production.
Adding More Enemies
Before adding more players to our game, let’s add more enemies to the MainScene to make the game more interesting. We do this by double-clicking the MainScene in Assets/FPS/Scenes to load the scene, opening the Assets/FPS/Prefabs/Enemies folder, and dragging either of the two enemies anywhere on the map. The sample project has a total of 12 enemies.
Next, we create a tag to assign to all enemies so that it is easy to retrieve them in script code. We select any of the enemies from the Hierarchy, click the tag in the Inspector window, and select Add Tag. Then we press the plus sign (+) icon, type "Enemy" into the input field, and click Save. Finally, we assign the new tag to all our enemies in the scene, including the initial two enemies in the game.
Now we can try running the game to make sure it works properly.
Importing Unity Transport
We’ll implement a simple multiplayer server using a basic networking package, Unity Transport, for this project. This package uses a UDP connection to maintain a fast communication channel between the server and the clients.
For Unity Mirror users, this demonstrates how we can integrate a more custom multiplayer server project with PlayFab’s APIs. But PlayFab also supports Unity Mirror, so if you’re already familiar with the framework, it may be helpful to look at this GitHub repository.
We open the Unity Package Manager in Window > Package Manager and click the plus sign (+) on the top-left to add a new package. Then we select Add package from git URL and type "com.unity.transport."
Creating the Server Script
It’s time to add a script for the multiplayer server logic. This server code accepts connections from client apps, receives game state data from each client to update its internal state, and broadcasts it to all connected players.
We start by creating a new C# script in the Assets/Scripts folder, where our new server code will go and give the file a name like "Server." Then from the file menu, we select Assets > Open C# Project to load the entire project, including the Unity Transport package we installed in Visual Studio. Then we double-click the new script to open the file.
We add the following using statements at the top of the code:
using System;
using Unity.Collections;
using Unity.Networking.Transport;
Next, we add the following member variables to the class and adjust the value of numEnemies
to the total number of enemies that we have in our game scene. This number must match to coordinate all game clients. This project will run locally right now, but we add a RunLocal
variable that we can configure using the Unity UI so that it’s easy to switch in the next part of the series, where we’ll build the server to upload to the cloud on PlayFab.
public bool RunLocal;
public NetworkDriver networkDriver;
private NativeList<NetworkConnection> connections;
const int numEnemies = 12;
private byte[] enemyStatus;
private int numPlayers = 0;
We use the following StartServer
method to create the network server on port 7777 with up to 16 connections, and initialize the enemyStatus
array that tracks the game state within the server:
void StartServer()
{
Debug.Log( "Starting Server" );
networkDriver = NetworkDriver.Create();
var endpoint = NetworkEndPoint.AnyIpv4;
endpoint.Port = 7777;
if( networkDriver.Bind( endpoint ) != 0 )
{
Debug.Log( "Failed to bind to port " + endpoint.Port );
}
else
{
networkDriver.Listen();
}
connections = new NativeList<NetworkConnection>( 16, Allocator.Persistent );
enemyStatus = new byte[ numEnemies ];
for( int i = 0; i < numEnemies; i++ )
{
enemyStatus[ i ] = 1;
}
}
We add the following method so that the server process cleans up properly when it’s finished running:
void OnDestroy()
{
networkDriver.Dispose();
connections.Dispose();
}
In the Start
method, we call the StartServer
method and leave a placeholder for a PlayFab start.
void Start()
{
if( RunLocal )
{
StartServer();
}
else
{
}
}
The most critical part of the server logic is the following Update
loop. In this method, we update the network, remove disconnected clients, accept new client connections, increment the player count, and go through each connection, updating the server’s internal game state and broadcasting the latest game state to the connected clients.
void Update()
{
networkDriver.ScheduleUpdate().Complete();
for( int i = 0; i < connections.Length; i++ )
{
if( !connections[ i ].IsCreated )
{
connections.RemoveAtSwapBack( i );
--i;
}
}
NetworkConnection c;
while( ( c = networkDriver.Accept() ) != default( NetworkConnection ) )
{
connections.Add( c );
Debug.Log( "Accepted a connection" );
numPlayers++;
}
DataStreamReader stream;
for( int i = 0; i < connections.Length; i++ )
{
if( !connections[ i ].IsCreated )
{
continue;
}
NetworkEvent.Type cmd;
while( ( cmd = networkDriver.PopEventForConnection( connections[ i ], out stream ) ) != NetworkEvent.Type.Empty )
{
if( cmd == NetworkEvent.Type.Data )
{
uint number = stream.ReadUInt();
if( number == numEnemies )
{
for( int b = 0; b < numEnemies; b++ )
{
byte isAlive = stream.ReadByte();
if( isAlive == 0 && enemyStatus[ b ] > 0 )
{
Debug.Log( "Enemy " + b + " destroyed by Player " + i );
enemyStatus[ b ] = 0;
}
}
}
}
else if( cmd == NetworkEvent.Type.Disconnect )
{
Debug.Log( "Client disconnected from server" );
connections[ i ] = default( NetworkConnection );
numPlayers--;
}
}
networkDriver.BeginSend( NetworkPipeline.Null, connections[ i ], out var writer );
writer.WriteUInt( numEnemies );
for( int b = 0; b < numEnemies; b++ )
{
writer.WriteByte( enemyStatus[ b ] );
}
networkDriver.EndSend( writer );
}
}
The full server script code is available here.
Adding the Server Script to the Project
The server script must run on the default scene. So, from Assets/FPS/Scenes, we double-click the IntroMenu scene to load it and right-click in the Hierarchy to create an empty GameObject. We give it a name like "NetworkingServer."
We click Add Component and select the server script to add it to this scene:
After ensuring it is enabled, we select the RunLocal flag. This flag is only for local testing of this server, so we will need to clear the checkbox when building the server executable for PlayFab in the next article when we deploy to it to PlayFab’s servers.
Building the Project into a Server Executable
We build the project as a "headless" network server by opening File > Build Settings, selecting the Server Build checkbox, and clicking Build. This creates a non-graphical, server-only project version that we can run without graphics and later, upload to run on PlayFab’s virtual machines.
And that’s all for our multiplayer server code setup!
Now, we click Build and save it to a separate folder. We see a console window like this when we run the server app.
Creating the Client
Now that we have the server program, we can set up the client side of our game to connect and play.
We clear the Server Build setting in Project Settings, as we are no longer building a "headless" app.
Also, clear the Server script from the IntroMenu because the client doesn’t need to run it.
We need to toggle these settings anytime we switch between building the game server and building the game client.
The client code goes inside the MainScene because that’s where the gameplay occurs. So, let’s double-click and open MainScene from Assets/FPS/Scenes and add a new script named Client.cs in the Assets/Scripts folder, like how we created the server script.
We add an empty GameObject named "NetworkClient" in MainScene and add the Client.cs script as a component.
We’re ready to add the client code, so we double-click the script file to open it in Visual Studio.
We add the following using statements to the code:
using UnityEngine;
using System;
using Unity.Networking.Transport;
using Unity.FPS.AI;
These are the member variables we need inside the class. These variables include a RunLocal
flag for the client similar to the flag in the server script, networking-related variables for managing the connection, and variables to track the enemies using the GameObject
class and the EnemyManager
class.
public bool RunLocal;
private NetworkDriver networkDriver;
private NetworkConnection networkConnection;
private bool isDone;
private EnemyManager enemyManager;
private GameObject[] enemies;
private byte[] enemyStatus;
private bool startedConnectionRequest = false;
private bool isConnected = false;
We need access to the EnemyManager
to reach the method to destroy a robot and trigger an explosion animation properly. As part of this, we must make a minor modification to the EnemyController
class in this sample. We right-click Definition in the EnemyManager
class, select Go To, and then right-click and select EnemyController
class.
Then, inside the EnemyController
class, we add the public
keyword in front of the m_Health member
variable to access this object from our client script.
We go back to the Client.cs script and add the following method that we use to connect to a server:
private void connectToServer( string address, ushort port )
{
Debug.Log( "Connecting to " + address + ":" + port );
networkDriver = NetworkDriver.Create();
networkConnection = default( NetworkConnection );
var endpoint = NetworkEndPoint.Parse( address, port );
networkConnection = networkDriver.Connect( endpoint );
startedConnectionRequest = true;
enemyManager = FindObjectOfType<EnemyManager>();
enemies = GameObject.FindGameObjectsWithTag( "Enemy" );
Debug.Log( "Detected " + enemies.Length + " enemies" );
Array.Sort( enemies, ( e1, e2 ) => e1.name.CompareTo( e2.name ) );
int length = enemies.Length;
enemyStatus = new byte[ length ];
for( var i = 0; i < length; i++ )
{
if( enemies[ i ] != null )
{
enemyStatus[ i ] = (byte)( enemies[ i ].activeSelf ? 1 : 0 );
}
}
}
This method starts a connection with the server and retrieves all the enemies in the scene. Then, because the object order is inconsistent with GameObject.FindGameObjectsWithTag
, it sorts them by name so that the game state remains consistent in all clients. It then updates the game state to match the enemy objects enabled and the objects disabled.
Also, we add an OnDestroy
handler to clean up the network after the program completes.
public void OnDestroy()
{
networkDriver.Dispose();
}
Next, we call the server connection method to connect to the local computer inside the Start
method, keeping the RunLocal
flag in mind:
void Start()
{
Debug.Log( "Starting Client" );
if( RunLocal )
{
connectToServer( "127.0.0.1", 7777 );
}
else
{
}
}
Finally, we add the Update
loop that handles the network connection status, gets the broadcasted game state from the server, and uses the EnemyController Health
object to trigger an explosion and destroy any robots reported gone from the server. It also updates the current game state of enemy statuses to check for enemies killed by this client’s player and then sends the latest game state to the server to synchronize.
void Update()
{
if( !startedConnectionRequest )
{
return;
}
networkDriver.ScheduleUpdate().Complete();
if( !networkConnection.IsCreated )
{
if( !isDone )
{
Debug.Log( "Something went wrong during connect" );
}
return;
}
DataStreamReader stream;
NetworkEvent.Type cmd;
if( !isConnected )
{
Debug.Log( "Connecting..." );
}
while( ( cmd = networkConnection.PopEvent( networkDriver, out stream ) ) != NetworkEvent.Type.Empty )
{
if( cmd == NetworkEvent.Type.Connect )
{
Debug.Log( "We are now connected to the server" );
isConnected = true;
}
else if( cmd == NetworkEvent.Type.Data )
{
uint value = stream.ReadUInt();
if( value == enemyStatus.Length )
{
for( int b = 0; b < enemyStatus.Length; b++ )
{
byte isAlive = stream.ReadByte();
if( enemyStatus[ b ] > 0 && isAlive == 0 )
{
Debug.Log( "enemy " + b + " dead" );
foreach( var en in enemyManager.Enemies )
{
if( en.name == enemies[ b ].name )
{
en.m_Health.Kill();
break;
}
}
enemyStatus[ b ] = 0;
}
}
}
}
else if( cmd == NetworkEvent.Type.Disconnect )
{
Debug.Log( "Client got disconnected from server" );
networkConnection = default( NetworkConnection );
isConnected = false;
}
}
for( var i = 0; i < enemies.Length; i++ )
{
if( enemyStatus[ i ] > 0 &&
( enemies[ i ] == null || !enemies[ i ].activeSelf ) )
{
enemyStatus[ i ] = 0;
}
}
if( isConnected )
{
networkDriver.BeginSend( networkConnection, out var writer );
writer.WriteUInt( (uint)enemyStatus.Length );
for( int b = 0; b < enemyStatus.Length; b++ )
{
writer.WriteByte( enemyStatus[ b ] );
}
networkDriver.EndSend( writer );
}
}
We’re finally ready to run and play our game with multiple players!
For reference, the full client code should look like this.
Running with the Server
After this is all done, we must be sure to enable RunLocal on the NetworkClient empty GameObject. We can build and save the game client in a separate folder, just as we did with the server. This allows us to run multiple game instances and test multiplayer communication.
We run the server program saved earlier and then open two or more game instances.
Now, when we shoot and destroy a robot, we should see that robot also explode on our other game instances.
Next Steps
In this article, we learned how to turn a Unity project into both a server build, and a game client build, and lay the foundations for multiplayer code. Then, we learned how to build the project into a server-only "headless" executable while the game builds normally.
Hop on over to the following article, where we integrate the PlayFab GSDK to be ready for cloud hosting, and then upload and deploy it to PlayFab through the dashboard.
To learn more about Azure PlayFab Analytics, and get overviews of features, quickstart guides, and tutorials, check out Azure PlayFab Analytics documentation.