Team : 8 persons

Duration : 8 weeks

Technology : Unity (C# - ECS)

Tasks performed :
  • - Creation and management of multiplayer sessions (ECS)
  • - Behavior of mutants and their animations
  • - Creation and network synchronization of key gameplay
  • elements (ECS)
  • - Creation of a player controller and its synchronization
  • - Setup and network synchronization of all sounds and music
  • using Wwise
  • - Setting up the tutorial
  • - Implementation of menus and HUD elements
  • Tools developed :
  • - Creation of a tool enabling the asynchronous loading of
  • multiple scenes
  • - Creation of a tool to monitor the appearance of mutants

Technical Presentation

We decided to produce a multiplayer objective extraction FPS (3 vs 3) for our third-year game, with a horde of mutants to slow down both teams.

It was a very challenging project because we decided to implement the multiplayer part in ECS, two concepts that we hadn't seen in class.

Préproduction

As mentioned earlier, I had never seen ECS or multiplayer in action. Therefore, before starting the project, I had to produce a technical demo to familiarize myself with these concepts. The goal of this demo was to synchronize two players who would be pursued by a horde of zombies, all entirely in ECS. To do this, I used several free Unity packages: Entities, Netcode for entities, Multiplayer Center, and Multiplayer Play Mode.

Since I didn't have access to paid packages for the project, I opted for a hybrid animation system.

Production

Player controller

We are at the beginning of the project, and we need to quickly produce a player controller with the various associated mechanics (jumping, running, walking, climbing, crouching, sliding). So I took on this task.

Problems

It must be usable quickly

The handling must be satisfactory when playing

Being able to differentiate between different states

I started by looking at player controllers from other games (Apex Legends, Titanfall, CS GO, Half-Life 2). I realized that they were based on the physics of their game and had unintended movement mechanics (bunny hopping, air strafing, etc.). So I decided to use Unity's physics for the same purpose. The player controller has two collars, each with a PhysicMaterial, one at the feet that manages sliding and the other for the rest of the body that allows the player to stick to walls.

To manage the different states, I set up a finite state machine.

Player synchronization

My next task was to synchronize the players' positions. The difficulty was that the player controller was a monobehavior and not an ECS system. To achieve this, I drew inspiration from hybrid ECS/Gameobject animations, so I locally attached the player to an ECS component, more specifically an IInputComponentData, so that the player could have authority over it. One small drawback: the data from this component is transmitted to the server but not to the other players. So, to make sure that this happens, on the server side I synchronize the position transmitted by the IInputComponentData to the LocalTransform of the entity, so that all players have the information.

Mutant behaviors

After a few attempts to apply ECS logic to the mutants, we decided to go with a shared authority system and use ECS only for the network part. We therefore chose to use the navmesh system implemented by Unity, as well as the synchronization already used for the player. Each player will control a certain number of mutants and transmit their current position and animation to the server.

Our zombie waits, runs, attacks, and dies. It “wakes up” when it detects a player in front of it, is shot, or a player recovers the objective. Regarding the zombie's health points, the server has authority over them to avoid unnecessary exchanges.

Mutant management tool

Since the map was quite large, it was necessary to control the number of mutants in each room to match the graphic designers' vision. To do this, I developed a tool that allows you to set up zones of influence divided into three sub-zones, while ensuring that it remains as user-friendly as possible for graphic designers.

Main gameplay elements

After synchronizing the player, it was necessary to set up the various gameplay elements as well as a victory/defeat condition.

Shooting between players

First, we gave players control over their health points. To prevent players from getting frustrated when they see their health points drop without knowing why, due to one of the players having a poor connection, I had an idea: make the player who is hit simulate the shot in their local game.

Here are the step-by-step instructions:

1 - Player A touches player B
2 - Player A transmits Player B's networkID and the shot information (origin and direction) to the server
3 - The server sends the information back to player B who was hit, using the networkID
4 - Player B simulates the shot information with a raycast, and if it hits, it removes health points

When a player drops to 0 health points, the game object that composes them is deactivated, except for the camera. This way, the player can see what happens after their death, and a few seconds later they reappear at their spawn point with all their health points. The entire respawn process is managed locally, which reduces communication with the server.

Shooting at mutants

As explained earlier, health points are managed by the server, so when the player shoots a mutant, they send an RPC with a reference to the entity that was hit and a boolean to indicate whether the head was hit. When its health points drop to 0, the entity is destroyed, the monobehaviour detects that it is no longer attached to an entity, the death animation plays, and a disappearance shader is launched.

Objective and victory/defeat conditions

The objective is somewhere on the map. When a player picks it up, the others see it on the back of the player model and see an icon on the HUD showing them where they need to take the objective (or defend it if the enemy picks it up). All the mutants on the map then become enraged, they are no longer in standby mode and only target the objective carrier. When you reach the extraction point with the flag on your back, a victory screen appears and you win (and vice versa).

To make the flag visible on the player model, I put it in the player hierarchy, and when it is retrieved, it is activated (and vice versa). So the objective on the map is deactivated when it is retrieved, and its local position is updated when it is reactivated.

As it was at the beginning of the project, I didn't know what the objective looked like. So I named all the elements of the objective based on a flag (capture the flag).

When the server is created, an entity with only one component named “FlagComponent” will be created. It will act as a singleton. When a player picks up the objective, they send an RPC containing a Boolean value to the server, which checks whether this is a state change (not picked up ⇌ picked up). If so, it updates its data.

The server retrieves the client's networkID, uses it to find the entity containing the player's position, attaches it to the player, and sends an RPC containing the entity to all players. The other players receive the information and activate the objective on the back of the player who picked it up, as well as the HUD. To drop the flag, the same system is used, except that the server returns the position and rotation of the objective as an RPC.

To activate the victory or defeat condition, another system retrieves the position of the FlagComponent and sends an RPC when it arrives in the correct extraction zone (while checking that the carrier is from the correct team).

public struct FlagComponent : IComponentData
{
    public Entity owner;
    public float3 position;
    public quaternion rotation;
    public bool isTake;
}

public struct FlagRPC : IRpcCommand
{
    public bool isTaken;
}
public struct FlagOwnerRPC : IRpcCommand
{
    public Entity flagOwner;
}

public struct FlagPositionRPC : IRpcCommand
{
    public Vector3 position;
    public quaternion rotation;
    public Entity oldOwner;
}



[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
partial struct FlagSystem : ISystem
{

    public void OnCreate(ref SystemState state)
    {
        //Flag Creation
        EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);
        Entity flagEntity = ecb.CreateEntity();

        ecb.AddComponent(flagEntity, new FlagComponent
        {
            owner = Entity.Null,
            isTake = false
        });

        ecb.Playback(state.EntityManager);
        ecb.Dispose();

        state.RequireForUpdate<FlagComponent>();
    }

    public void OnUpdate(ref SystemState state)
    {
        if (SystemAPI.TryGetSingleton<FlagComponent>(out _))
        {
            EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);
            Entity flagEntity = SystemAPI.GetSingletonEntity<FlagComponent>();
            FlagComponent flagComponent = state.EntityManager.GetComponentData<FlagComponent>(flagEntity);



            foreach (var (flagRPC, rpcCommandRequest, entityRpc) in SystemAPI.Query<RefRO<FlagRPC>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
            {
                if (flagComponent.isTake != flagRPC.ValueRO.isTaken)
                {
                    flagComponent.isTake = flagRPC.ValueRO.isTaken;

                    if (flagComponent.isTake)
                    {
                        // take flag
                        Entity rpcGetFlag = ecb.CreateEntity();

                        foreach (var (ghostOwner, player) in SystemAPI.Query<RefRO<GhostOwner>>().WithAll<PlayerSyncedData>().WithEntityAccess())
                        {
                            NetworkId playerTakerId = state.EntityManager.GetComponentData<NetworkId>(rpcCommandRequest.ValueRO.SourceConnection);

                            if (ghostOwner.ValueRO.NetworkId == playerTakerId.Value)
                            {
                                flagComponent.owner = player;
                                ecb.AddComponent(rpcGetFlag, new FlagOwnerRPC { flagOwner = player });
                            }
                        }

                        ecb.AddComponent(rpcGetFlag, new SendRpcCommandRequest());

                    }
                    else
                    {
                        //Drop flag
                        NetworkId playerCommand = state.EntityManager.GetComponentData<NetworkId>(rpcCommandRequest.ValueRO.SourceConnection);
                        GhostOwner flagOwner = state.EntityManager.GetComponentData<GhostOwner>(flagComponent.owner);

                        if (playerCommand.Value == flagOwner.NetworkId)
                        {
                            Entity rpcDropFlag = ecb.CreateEntity();

                            ecb.AddComponent(rpcDropFlag, new FlagPositionRPC
                            {
                                position = flagComponent.position,
                                rotation = flagComponent.rotation,
                            });

                            ecb.AddComponent(rpcDropFlag, new SendRpcCommandRequest());


                            flagComponent.owner = Entity.Null;
                        }
                        else
                        {
                            flagComponent.isTake = !flagRPC.ValueRO.isTaken;
                        }
                    }
                }

                SystemAPI.SetSingleton<FlagComponent>(flagComponent);
                ecb.DestroyEntity(entityRpc);
            }

            if (flagComponent.owner != Entity.Null && flagComponent.isTake)
            {
                LocalTransform playerTransform = state.EntityManager.GetComponentData<LocalTransform>(flagComponent.owner);

                flagComponent.position = playerTransform.Position;
                flagComponent.rotation = playerTransform.Rotation;
                SystemAPI.SetSingleton<FlagComponent>(flagComponent);
            }

            ecb.Playback(state.EntityManager);
            ecb.Dispose();
        }
    }
}

Multiplayer session

For a while, I looked into having multiple parties at the same time, but it was complicated and tedious because I didn't know how to go about it. First, I implemented the Unity Multiplayer Service package and set up sessions as described in the documentation, hoping that it would achieve the desired result. Unfortunately, it didn't work, so I had to find another solution.

So I started looking around the “ClientServerBootstrap” class, and I was able to understand the basics of how it worked and what functions it used to run the network. I then took some of its functions and set up the current system, a main server that creates other servers to host the game. Once the game is over, the players reconnect to the main server. I also coupled this with Unity's lobby system so that players can join each other more easily.

[UnityEngine.Scripting.Preserve]
public class Bootstrap : ClientServerBootstrap
{
    //bootstrap no longer manages the connection refer to the NetworkConnectionystem
    public override bool Initialize(string defaultWorldName)
    {
        PlayType requestedPlayType = RequestedPlayType;

        if (!DetermineIfBootstrappingEnabled())
        {
            return false;
        }

        if (requestedPlayType != PlayType.Client)
        {
            CreateServerWorld("MainServerWorld");
        }

        if (requestedPlayType != PlayType.Server)
        {
            CreateClientWorld("ClientWorld");
            AutomaticThinClientWorldsUtility.BootstrapThinClientWorlds();
        }

        return true;
    }

}

[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial class NetworkConnectionServerSystem : SystemBase
{
    private ushort mainServPort = 7979;
    private ushort nextServPort;

    private List<WorldInfo> allServWorlds = new List<WorldInfo>();

    bool isInitialized = false;
    protected override void OnCreate()
    {

        if (World.Name == "MainServerWorld")
        {
            nextServPort = mainServPort;
            nextServPort++;
        }
        else
        {
            isInitialized = true;
        }

        base.OnCreate();
    }

    protected override void OnUpdate()
    {
        if (!isInitialized)
        {
            NetworkStreamDriver driver = SystemAPI.GetSingleton<NetworkStreamDriver>();

#if UNITY_EDITOR
            driver.Listen(NetworkEndpoint.Parse("127.0.0.1", mainServPort));
#else
            driver.Listen(NetworkEndpoint.Parse("51.210.104.120", mainServPort));
#endif

            isInitialized = true;

            Debug.Log("[MAIN SERVER] LISTEN");
        }
        else if (World.Name == "MainServerWorld")
        {
            EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);

            foreach (var (rpc, receiveCommand, entityRpc) in SystemAPI.Query<RefRO<CreateOrJoinNewServerWorld>, RefRO<ReceiveRpcCommandRequest>>().WithEntityAccess())
            {
                Entity rpcToClient = ecb.CreateEntity();

                bool worldExist = false;
                ushort port = nextServPort;

                Debug.Log("[NetworkConnectionServerSystem::Update] - Receive rpc create world");

                if (rpc.ValueRO.sessionId != "Tutorial")
                {
                    foreach (WorldInfo world in allServWorlds)
                    {
                        if (world.sessionId == rpc.ValueRO.sessionId)
                        {
                            worldExist = true;
                            port = world.port;
                        }
                    }
                }

                if (!worldExist)
                {
                    WorldInfo newWorld = new WorldInfo();

                    newWorld.port = nextServPort;
                    newWorld.sessionId = rpc.ValueRO.sessionId;

                    CreateServerSession(newWorld.port);

                    allServWorlds.Add(newWorld);
                    nextServPort++;
                }

                ecb.AddComponent(rpcToClient, new InfoPortConnection { connexionPort = port });
                ecb.AddComponent(rpcToClient, new SendRpcCommandRequest { TargetConnection = receiveCommand.ValueRO.SourceConnection });

                ecb.DestroyEntity(entityRpc);
            }

            ecb.Playback(EntityManager);
            ecb.Dispose();
        }
    }


    private World CreateServerSession(ushort port)
    {
        World servWorld = ClientServerBootstrap.CreateServerWorld("ServerWorld " + allServWorlds.Count);
        ServerSessionSystem sessionSystem = servWorld.GetExistingSystemManaged<ServerSessionSystem>();

        sessionSystem.sessionPort = port;

        Debug.Log("Create a new world port " + port);

        return servWorld;
    }

}
#if !UNITY_SERVER
[WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)]
public partial class ClientConnectionSystem : SystemBase
{
    public static ushort sessionPort;
    private ushort mainServPort = 7979;

    bool isInitialized = false;
    bool test = true;

    float timerTimeout = 10f;
    float timeTimeout = 10f;

    protected override void OnUpdate()
    {
        if (!isInitialized)
        {
            NetworkStreamDriver driver = SystemAPI.GetSingleton<NetworkStreamDriver>();

#if UNITY_EDITOR
            driver.Connect(EntityManager, NetworkEndpoint.Parse("127.0.0.1", mainServPort));
#else
            driver.Connect(EntityManager, NetworkEndpoint.Parse("51.210.104.120", mainServPort));
#endif
            sessionPort = mainServPort;
            
            isInitialized = true;
        }
        else
        {
            if (!SystemAPI.TryGetSingleton<NetworkStreamConnection>(out _))
            {
                NetworkStreamDriver driver = SystemAPI.GetSingleton<NetworkStreamDriver>();
#if UNITY_EDITOR
                driver.Connect(EntityManager, NetworkEndpoint.Parse("127.0.0.1", sessionPort));
#else
                driver.Connect(EntityManager, NetworkEndpoint.Parse("51.210.104.120", sessionPort));
#endif
                Debug.LogError("[ClientConnectionSystem::Update] - NetworkStreamConnection entity not found try to reconect port " + sessionPort);
                Game.Instance.connected = false;
            }
            else if (!SystemAPI.TryGetSingleton<NetworkId>(out _))
            {
                Debug.LogWarning("[ClientConnectionSystem::Update] - NetworkId entity not found time out in " + timerTimeout);
                timerTimeout -= SystemAPI.Time.DeltaTime;

                if (timerTimeout < 0)
                {
                    EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);
                    Entity connectionEntity = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();

                    ecb.AddComponent<NetworkStreamRequestDisconnect>(connectionEntity);
                    timerTimeout = timeTimeout;

                    ecb.Playback(EntityManager);
                    ecb.Dispose();
                }

                Game.Instance.connected = false;
            }
            else
            {
                EntityCommandBuffer ecb = new EntityCommandBuffer(Allocator.Temp);
                bool hasConnection = EntityManager.CreateEntityQuery(typeof(NetworkStreamInGame)).CalculateEntityCount() > 0;
                //automatic go in game
                if (mainServPort != sessionPort && !hasConnection)
                {
                    Entity connectionEntity = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();
                    Entity rpcEntity = ecb.CreateEntity();

                    ecb.AddComponent<NetworkStreamInGame>(connectionEntity);

                    ecb.AddComponent(rpcEntity, new InGameStateRequest());
                    ecb.AddComponent(rpcEntity, new SendRpcCommandRequest());
                    Game.Instance.connected = true;

                }

                foreach (var (rpc, entityRpc) in SystemAPI.Query<RefRO<InfoPortConnection>>().WithAll<ReceiveRpcCommandRequest>().WithEntityAccess())
                {
                    Entity connectionEntity = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();

                    ecb.AddComponent<NetworkStreamRequestDisconnect>(connectionEntity);
                    sessionPort = rpc.ValueRO.connexionPort;

                    ecb.DestroyEntity(entityRpc);
                }

                if (Game.Instance.connectMainServ)
                {
                    Entity connectionEntity = SystemAPI.GetSingletonEntity<NetworkStreamConnection>();

                    Game.Instance.teamWin = -1;
                    Game.Instance.connectMainServ = false;
                    Game.Instance.playerTeam = 0;

                    Game.Instance.flagOwner = null;
                    Game.Instance.playerList.Clear();

                    sessionPort = mainServPort;
                    ecb.AddComponent<NetworkStreamRequestDisconnect>(connectionEntity);
                }
                timerTimeout = timeTimeout;

                ecb.Playback(EntityManager);
                ecb.Dispose();
            }
        }
    }

}
#endif
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation)]
public partial class ServerSessionSystem : SystemBase
{
    public ushort sessionPort;
    bool isInitialized = false;
    protected override void OnUpdate()
    {
        if (!isInitialized && sessionPort != 0 && World.Name != "MainServerWorld")
        {
            NetworkStreamDriver driver = SystemAPI.GetSingleton<NetworkStreamDriver>();

#if UNITY_EDITOR
            driver.Listen(NetworkEndpoint.Parse("127.0.0.1", sessionPort));
#else
            driver.Listen(NetworkEndpoint.Parse("51.210.104.120", sessionPort));
#endif

            isInitialized = true;

            //GUID Server sub scene
            var sceneGuid = new Unity.Entities.Hash128("3ff0f8acaa208c34aa2308dd8ea7a45f");

            // Load subscene (asynchrone)
            SceneSystem.LoadSceneAsync(World.Unmanaged, sceneGuid, new SceneSystem.LoadParameters
            {
                AutoLoad = true
            });
        }
    }
}
struct WorldInfo
{
    public FixedString128Bytes sessionId;
    public ushort port;
}

public struct CreateOrJoinNewServerWorld : IRpcCommand
{
    public FixedString128Bytes sessionId;
}

public struct InfoPortConnection : IRpcCommand
{
    public ushort connexionPort;
}