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.
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.
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.
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.
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.
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.
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.
After synchronizing the player, it was necessary to set up the various gameplay elements as well as a victory/defeat condition.
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.
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.
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();
}
}
}
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 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;
}