Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Unity on Azure PlayFab Part 3: Setting Up a Multiplayer Server (Part 1)

0.00/5 (No votes)
16 Feb 2022 1  
In this article, we’ll begin creating the backend code that this game will connect to for multiplayer gameplay.

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:

C#
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.

C#
public bool RunLocal;
public NetworkDriver networkDriver;
private NativeList<NetworkConnection> connections;
const int numEnemies = 12; // Total number of enemies
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:

C#
void StartServer()
{
    Debug.Log( "Starting Server" );

    // Start transport 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:

C#
void OnDestroy()
{
    networkDriver.Dispose();
    connections.Dispose();
}

In the Start method, we call the StartServer method and leave a placeholder for a PlayFab start.

C#
void Start()
{
    if( RunLocal )
    {
        StartServer(); // Run the server locally
    }
    else
    {
        // TODO: Start from PlayFab configuration
    }
}

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.

C#
void Update()
{
    networkDriver.ScheduleUpdate().Complete();

    // Clean up connections
    for( int i = 0; i < connections.Length; i++ )
    {
        if( !connections[ i ].IsCreated )
        {
            connections.RemoveAtSwapBack( i );
            --i;
        }
    }

    // Accept new connections
    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 ) // Check that the number of enemies match
                {
                    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--;
            }
        }

        // Broadcast Game State
        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:

C#
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.

C#
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:

C#
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" );
    // Sort the array by name to keep it consistent across clients
    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.

C#
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:

C#
void Start()
{
    Debug.Log( "Starting Client" );
    if( RunLocal )
    {
        connectToServer( "127.0.0.1", 7777 );
    }
    else
    {
        // TODO: Start from PlayFab configuration
    }
}

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.

C#
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 ) // Make sure the enemy length is consistent
            {
                for( int b = 0; b < enemyStatus.Length; b++ )
                {
                    byte isAlive = stream.ReadByte();
                    if( enemyStatus[ b ] > 0 && isAlive == 0 ) // Enemy is alive locally but dead on the server
                    {
                        Debug.Log( "enemy " + b + " dead" );
                        // Find the right enemy and "kill" it for the animation
                        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;
        }
    }

    // Update the status with local game state
    for( var i = 0; i < enemies.Length; i++ )
    {
        if( enemyStatus[ i ] > 0 &&
            ( enemies[ i ] == null || !enemies[ i ].activeSelf ) )
        {
            enemyStatus[ i ] = 0;
        }
    }

    // Send latest status to the server
    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.

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here