1 - First-Person Shooter: Getting started

Tutorial: First-Person Shooter in Unity – Getting started

In this series of Elympics tutorials, you’ll be creating a simple FPS game for 2 players. It’ll help you understand how our tool works and what you can achieve with it when building a multiplayer shooter. 💪

We will focus on implementing the core FPS components such as:

  • player movement 🚶
  • managing players’ HP system ❤️‍🩹
  • weapons shooting raycasts and projectiles 🔫

We’ll start with jumping capsules, then go to managing cameras and creating different weapons, and finally handle synchronizing player animations.

All of that fully deterministic and server-authoritative!

Project setup

Before you start, you’ll need to add Elympics to your project. Just like in the Pong2D, you can do it using the Package Manager.

After adding the Elympics package, you’ll need to create a new game config:

First-Person Shooter

First-Person Shooter

Use the right-click menu to add the Elympics System object to the hierarchy too.

First-Person Shooter

After doing so, you’ll start with a very simple scene setup and the implementation of basic character movement. All you need at this point is a plane.

First-Person Shooter

Player game object

Now, let’s focus on creating the player. You’ll have to create a new, empty GameObject that will be the root for all the components of the player prefab. Make sure that the created object is in position 0,0,0 of the game world and add the following components to it:

  • Elympics Behaviour;
  • Rigidbody;
  • Elympics Rigid Body Synchronizer (added by the button in the Elympics Behavior component).

In the Rigidbody component, you can immediately change Interpolate to “Interpolate” and Collision Detection to “Continuous” and then block all the rotation axes in Constraints.

First-Person Shooter

Now, add 3D Object → Capsule as the child of the player object. Make sure that its position is 0,1,0 in relation to the parent so that the entire “body” of your player is above the ground.

First-Person Shooter

In addition to the Capsule Collider, you can add a properly scaled Cube and move it along the Z axis, which will serve as an indicator of which direction the player’s body is currently facing. This cube doesn’t need any colliders and is only a visual aid for the observer, so if you decide to add it, remove the collider from it.

First-Person Shooter

We’re ready! 🔥

Everything is set and we can start coding! In the next part we’ll focus on player’s movement! 🏃🏼

2 - First-Person Shooter: Player movement

Tutorial: First-Person Shooter in Unity - Creating player movement using Elympics

This is Elympics First-Person Shooter tutorial: part 2. In this part we’ll be creating player movement using Elympics. See: Part 1.

Sending input to the server

Start the implementation of your player’s movement by creating an InputProvider script. It’ll be responsible for collecting and storing all the inputs entered by the player using a specific controller (in this case: a keyboard and a mouse).

Our goal is to separate the responsibilities of collecting input and sending it to the server into two different scripts, but nothing prevents you from putting all the code in one class.

Let’s start by creating a new InputProvider.cs script. It’ll serve as a support for another script that deals with sending input to the server, so begin with creating a special method that enables obtaining data on the player input.

To allow the player to perform the basic X and Z axis movement, your InputProvider class should look like this:

public class InputProvider : MonoBehaviour
{
    private Vector2 movement = Vector2.zero;
    public Vector2 Movement => movement;

    private void Update()
    {
		movement.x = Input.GetAxis("Horizontal");
		movement.y = Input.GetAxis("Vertical");
    }
}

This class focuses only on collecting, remembering and sharing input. This script will be used by another class that already sends input directly to the server. For this, you’ll need to create a new class: InputController.cs.

If you want the player to send the input, this class must change its inheritance from the MonoBehaviour class to the ElympicsMonoBehaviour class which you can find in the Elympics namespace. Additionally, the handling of sending and applying inputs itself requires the implementation of the IInputHandler interface.

using Elympics;

public class InputController : ElympicsMonoBehaviour, IInputHandler
{
    public void OnInputForBot(IInputWriter inputSerializer)
    {
   	 
    }

    public void OnInputForClient(IInputWriter inputSerializer)
    {
   	 
    }
}

As the created class will be based entirely on the inputs provided by the InputProvider class, you can add the RequireComponent attribute to this class to make sure that the InputProvider won’t be null.

Once you make sure that the InputProvider class exists, you’ll still need to obtain an appropriate reference to it. For this purpose, you’ll need to use the GetComponent method in the Initialize method, the implementation of which requires another Elympics interface: IInitializable. This ensures that you’ll get a reference to the InputProvider class before any other method related to the IInputHandler interface is called.

[RequireComponent(typeof(InputProvider))]
public class InputController : ElympicsMonoBehaviour, IInputHandler, IInitializable
{
    private InputProvider inputProvider = null;

    public void Initialize()
    {
		this.inputProvider = GetComponent<InputProvider>();
    }

    [...]
}

The IInputHandler interface requires the implementation of two methods responsible for sending all the player inputs to the server: OnInputForBot and OnInputForClient. Both of these methods have an argument of the IInputWriter type, which is the key object responsible for all the data sent by players to the server. In this project, you do not provide a separate logic for the operation of bots, so you can create a single, universal method that will deal with saving the appropriate data to the inputSerializer.

Let’s create a new SerializeInput method:

    private void SerializeInput(IInputWriter inputWriter)
    {
		inputWriter.Write(inputProvider.Movement.x);
		inputWriter.Write(inputProvider.Movement.y);
    }

Then, call it in the interface methods responsible for sending the input:

    public void OnInputForBot(IInputWriter inputSerializer)
    {
		SerializeInput(inputSerializer);
    }

    public void OnInputForClient(IInputWriter inputSerializer)
    {
		SerializeInput(inputSerializer);
    }

The above code works as follows:

  • At the start of the game, the InputController script calls the Initialize method, which will provide a reference to the InputProvider class instance.
  • The InputProvider class will save and update frame by frame the state of the input entered by the player.
  • During each Elympics tick, the InputController class will execute the OnInputForBot or OnInputForClient method and then the SerializeInput method. This method will retrieve the saved input from InputProvider and pass it to the IInputWriter object, thanks to which the input entered by the player will be sent to the server.

Receiving and applying input

You’ve already handled the basic way of sending the input to the server, but you still need to implement its application. Once the server receives the input from the player, it will make it available through the TryGetInput method of the current ElympicsBehaviour (both on the server side and at the sending client). This method should be called in ElympicsUpdate (which is related to the implementation of another interface: IUpdatable).

The TryGetInput method provides the key object of the IInputReader type. It’s important to use it to read all the variables that you’ve saved on the client side and sent to the server, always in the same order!

[RequireComponent(typeof(InputProvider))]
public class InputController : ElympicsMonoBehaviour, IInputHandler, IInitializable, IUpdatable
{
    [...]
    public void ElympicsUpdate()
    {
        var forwardMovement = 0.0f;
        var rightMovement = 0.0f;

        if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out var inputReader))
        {
            inputReader.Read(out forwardMovement);
            inputReader.Read(out rightMovement);
        }
    }
}

Although each player will use this script, the input will serve for a specific player only. That’s why you need to know which object is controlled by the given player (the TryGetInput method checks if the received input is intended for the given player). For this purpose, you’ll need to create a PlayerData class that will hold all the data for each player, e.g. Player Id.

public class PlayerData : MonoBehaviour
{
    [Header("Parameters:")]
    [SerializeField] private int playerId = 0;

    public int PlayerId => playerId;
}

All the scripts prepared so far will be added to the player’s prefab object. Once you have a class that holds the data about your player, add it to the previously created InputController:

[RequireComponent(typeof(InputProvider))]
public class InputController : ElympicsMonoBehaviour, IInputHandler, IInitializable
{
    [SerializeField] private PlayerData playerData = null;

    private InputProvider inputProvider = null;

    public void Initialize()
    {
	[...]

Now, use the saved connection with the player’s ID in the TryGetInput method:

public void ElympicsUpdate()
{
    var forwardMovement = 0.0f;
    var rightMovement = 0.0f;

    if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(playerData.PlayerId), out var inputReader))
    {
        inputReader.Read(out forwardMovement);
        inputReader.Read(out rightMovement);
    }
}

From now on, any code after the written if statement will be executed only for the intended player. It means that you can start implementing subsequent classes that perform a specific logic and behavior of the player!

Player movement

Start with the MovementController class:

[RequireComponent(typeof(Rigidbody))]
public class MovementController : ElympicsMonoBehaviour
{
	[Header("Parameters:")]
	[SerializeField] private float movementSpeed = 0.0f;
	[SerializeField] private float acceleration = 0.0f;

	private new Rigidbody rigidbody = null;

	private bool IsGrounded => Physics.Raycast(transform.position + new Vector3(0, 0.05f, 0), Vector3.down, 0.1f);

	private void Awake()
	{
		rigidbody = GetComponent<Rigidbody>();
	}

	public void ProcessMovement(float forwardMovementValue, float rightMovementValue)
	{
		Vector3 inputVector = new Vector3(forwardMovementValue, 0, rightMovementValue);
		Vector3 movementDirection = inputVector != Vector3.zero ? this.transform.TransformDirection(inputVector.normalized) : Vector3.zero;

		ApplyMovement(movementDirection);
	}

	private void ApplyMovement(Vector3 movementDirection)
	{
		Vector3 defaultVelocity = movementDirection * movementSpeed;
		Vector3 fixedVelocity = Vector3.MoveTowards(rigidbody.velocity, defaultVelocity, Elympics.TickDuration * acceleration);

		rigidbody.velocity = new Vector3(fixedVelocity.x, rigidbody.velocity.y, fixedVelocity.z);
	}
}

In the ProcessMovement method, this class will receive floats determining the player’s forward and sideways movement, then determine the correct direction of movement and finally modify the velocity of the rigidbody component (ApplyMovement).

Now, you can add the created MovementController class to your InputController:

[RequireComponent(typeof(InputProvider))]
public class InputController : ElympicsMonoBehaviour, IInputHandler, IInitializable, IUpdatable
{
	[SerializeField] private PlayerData playerData = null;
	[SerializeField] private MovementController movementController = null;

	[...]

	public void ElympicsUpdate()
	{
		var forwardMovement = 0.0f;
		var rightMovement = 0.0f;

		if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(playerData.PlayerId), out var inputDeserializer))
		{
			inputDeserializer.Read(out forwardMovement);
			inputDeserializer.Read(out rightMovement);

			ProcessMovement(forwardMovement, rightMovement);
		}
	}

	private void ProcessMovement(float forwardMovement, float rightMovement)
	{
    	movementController.ProcessMovement(forwardMovement, rightMovement);
	}
}

The last step is to prepare the player’s prefab properly. Add all the scripts prepared before to the previously created object with the capsule and assign references to it:

First-Person Shooter

Now, rename the player object to Player0 and create a prefab from this object. Then, change the appropriate values in ElympicsBehaviour by setting the prediction for player 0 and manually assign the NetworkID to make it easier to distinguish between them:

First-Person Shooter

All you need to do now is to duplicate this object in the scene, rename it to Player1, and create a prefab variant from this object:

First-Person Shooter

In the second player’s prefab (Player1), change the assigned PlayerId in PlayerData to 1:

First-Person Shooter

Also, modify ElympicsBehaviour for the second player:

First-Person Shooter

From now on, you’ll be able to move players in the scene in both Local Player and Bots and Half Remote modes!

Running Half-Remote mode is described in the Pong 2D tutorial, part 2.

Jumping

When creating a movement, you cannot forget about the jump. Start adding the jump button once again with InputProvider and allowing InputController to read the value of the jump button:

public class InputProvider : MonoBehaviour
{
	private Vector2 movement = Vector2.zero;
	public Vector2 Movement => movement;

	public bool Jump { get; private set; }

	private void Update()
	{
    	movement.x = Input.GetAxis("Horizontal");
    	movement.y = Input.GetAxis("Vertical");

    	Jump = Input.GetButton("Jump");
	}
}

From now on, InputController will be able to get the Jump value and pass it to the server. Both InputController and MovementController must be updated with an additional variable:

InputController.cs:

	[...]

	private void SerializeInput(IInputWriter inputWriter)
	{
		inputWriter.Write(inputProvider.Movement.x);
		inputWriter.Write(inputProvider.Movement.y);

		inputWriter.Write(inputProvider.Jump);
	}

	public void ElympicsUpdate()
	{
		var forwardMovement = 0.0f;
		var rightMovement = 0.0f;
		bool jump = false;

		if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out var inputDeserializer))
		{
			inputReader.Read(out forwardMovement);
			inputReader.Read(out rightMovement);
			inputReader.Read(out jump);

			ProcessMovement(forwardMovement, rightMovement, jump);
		}
	}


	private void ProcessMovement(float forwardMovement, float rightMovement, bool jump)
	{
		movementController.ProcessMovement(forwardMovement, rightMovement, jump);
	}
}

MovementController.cs:

	[...]
	[SerializeField] private float jumpForce = 0.0f;
	[...] 

	public void ProcessMovement(float forwardMovementValue, float rightMovementValue, bool jump)
	{
		Vector3 inputVector = new Vector3(forwardMovementValue, 0, rightMovementValue);
		Vector3 movementDirection = inputVector != Vector3.zero ? this.transform.TransformDirection(inputVector.normalized) : Vector3.zero;

		ApplyMovement(movementDirection);

		if (jump && IsGrounded)
			ApplyJump();
	}

	private void ApplyJump()
	{
		rigidbody.AddForce(Vector3.up * jumpForce, ForceMode.Impulse);
	}

To easily control the jump force from the inspector, add the following variable:

	[SerializeField] private float jumpForce = 0.0f; 

(set its value to 5.0f in this project)

You can already check if the character is standing on the ground as you have IsGrounded in MovementController.cs.

It’s alive!

Now, when the player presses the space bar and stands on the ground, their character will pop up! 🤸‍♀️

First-Person Shooter

View on server side

In the next part we’ll focus on assigning a camera to the player! 👀📽

3 - First-Person Shooter: Player's camera

Tutorial: First-Person Shooter in Unity - Assigning a camera to the player

This is Elympics First-Person Shooter tutorial: part 3. In this part we’ll be creating separate camera for each player. See: Part 2.

Assigning a camera to the player

Currently, each player uses the same camera that watches the entire scene from above. However, in FPS games, each player should have a distinct camera assigned to their character (player prefab).

To fix it, you’ll need to remove the current camera from the scene and, then, edit the Player0 prefab. To do this, create a new Empty Game Object within and set its name to FirstPersonViewController: this object will be the main container for your camera, weapons held by the player, etc. Set its position to 0, 1.75, 0 so that the height of the object corresponds to where the player’s camera should be. Then, add ElympicsBehaviour with Predictable For settings appropriate for the player it belongs to and with transform synchronization to it. Now, other players will be able to see the direction in which you’re looking when you’re moving the camera.

First-Person Shooter

The next step is to add a camera object as a child of the container you’ve just created. The default settings are sufficient for the needs of our guide, but feel free to modify them as you want.

Each player already has their own camera. All we have to do now is to turn off all the cameras of the players who are not our characters at the start of the game. To do this, you’ll need to create a new CameraInitailizer.cs script:

[RequireComponent(typeof(PlayerData))]
public class CameraInitializer : ElympicsMonoBehaviour, IInitializable
{
	public void Initialize()
	{
		var playerData = GetComponent<PlayerData>();

		InitializeCameras(playerData);
	}

	private void InitializeCameras(PlayerData playerData)
	{
		var camerasInChildren = GetComponentsInChildren<Camera>();

		bool enableCamera = false;

		if (Elympics.IsClient)
			enableCamera = (int)Elympics.Player == playerData.PlayerId;
		else if (Elympics.IsServer)
			enableCamera = playerData.PlayerId == 0;

		foreach (Camera camera in camerasInChildren)
		{
			camera.enabled = enableCamera;

			if (camera.TryGetComponent<AudioListener>(out AudioListener audioListener))
			{
				Destroy(audioListener);
			}
		}
	}
}

This script works as follows: The Elympics Initialize() method ensures that the entire Elympics system is ready to be used. In this method, you’re getting the PlayerData component thanks to which you’re able to find out what Player Id the given player object has assigned to it. Then, you call the InitializeCameras method that collects all the cameras assigned to your player object (in case you’d like to create more than one camera, e.g. for rendering the player’s hands separately) and manages them accordingly.

Now, check whether you’re a game client or a server. If you’re a client, compare whether the given player’s prefab (their PlayerId) corresponds to the id of your client. If so, the cameras for the given object will remain unchanged, and if not, you’ll need to turn them off.

You may also check whether you’re a server so that you’ll have a preview of the game on the server side from the player 0 perspective when testing the game in the Half Remote mode.

Add the above script to the parent object of the player prefab. From now on, each player will be able to observe the game fully independently by using their own camera!

First-Person Shooter

Player 1 is moving on the scene (View for Player 0)

Controlling the player’s camera

Each player already has an individual camera assigned. The next step is to allow the player to look around the scene and rotate the character.

Controlling the camera will be done with the mouse, so once again we will start our work with the InputProvider script:

public class InputProvider : MonoBehaviour
{
	[SerializeField] private float mouseSensivity = 1.5f;
	[SerializeField] private Vector2 verticalAngleLimits = Vector2.zero;
	[SerializeField] private bool invertedMouseXAxis = false;
	[SerializeField] private bool invertedMouseYAxis = false;

	private Vector2 movement = Vector2.zero;
	public Vector2 Movement => movement;

	private Vector3 mouseAxis = Vector3.zero;
	public Vector3 MouseAxis => mouseAxis;

	public bool Jump { get; private set; }

	private void Update()
	{
		movement.x = Input.GetAxis("Horizontal");
		movement.y = Input.GetAxis("Vertical");

		var mouseX = Input.GetAxis("Mouse X") * (invertedMouseXAxis ? -1 : 1);
		var mouseY = Input.GetAxis("Mouse Y") * (invertedMouseYAxis ? -1 : 1);
		var newMouseAngles = mouseAxis + new Vector3(mouseY, mouseX) * mouseSensivity;
		mouseAxis = FixTooLargeMouseAngles(newMouseAngles);

		Jump = Input.GetButton("Jump");
	}

	private Vector3 FixTooLargeMouseAngles(Vector3 mouseAngles)
	{
		mouseAngles.x = Mathf.Clamp(mouseAngles.x, verticalAngleLimits.x, verticalAngleLimits.y);

		return mouseAngles;
	}
}

There are new variables responsible for mouse sensitivity, angle limits and flags used to recognize whether the loaded values are to be inverted. The MouseAxis variable will be used to obtain the value of the mouse by the InputController.

The script will read the mouse movement values frame by frame, modifying them with sensitivity value, and, finally, modifying the up-down viewing angles if they turn out to be too large.

There won’t be many changes in InputController.cs, but you’ll have to handle new angle values:

	[...]
	private void SerializeInput(IInputWriter inputWriter)
	{
		inputWriter.Write(inputProvider.Movement.x);
		inputWriter.Write(inputProvider.Movement.y);

		inputWriter.Write(inputProvider.MouseAxis.x);
		inputWriter.Write(inputProvider.MouseAxis.y);
		inputWriter.Write(inputProvider.MouseAxis.z);

		inputWriter.Write(inputProvider.Jump);
	}

	public void ElympicsUpdate()
	{
		var forwardMovement = 0.0f;
		var rightMovement = 0.0f;
		bool jump = false;

		if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out var inputDeserializer))
		{
			inputReader.Read(out forwardMovement);
			inputReader.Read(out rightMovement);

			inputReader.Read(out float xRotation);
			inputReader.Read(out float yRotation);
			inputReader.Read(out float zRotation);

			inputReader.Read(out jump);

			ProcessMovement(forwardMovement, rightMovement, jump);
			ProcessMouse(Quaternion.Euler(new Vector3(xRotation, yRotation, zRotation)));
		}
	}

	private void ProcessMouse(Quaternion mouseRotation)
	{
		viewController.ProcessView(mouseRotation);
	}
	
	[...]

Just like in the case of moving, you’ll need to write the values using the inputWriter that will be sent to the server later on. Next, read these values and create Quaternion angles from the obtained values and pass them on to the ViewController. It’ll be a new class that will deal with looking around the camera in your project, so you’ll need to create a new script: ViewController.cs:

public class ViewController : MonoBehaviour
{
	[Header("References:")]
	[SerializeField] private Transform verticalRotationTarget = null;
	[SerializeField] private Transform horizontalRotationTarget = null;

	public void ProcessView(Quaternion mouseRotation)
	{
		horizontalRotationTarget.localRotation = Quaternion.Euler(0, mouseRotation.eulerAngles.y, 0);
		verticalRotationTarget.localRotation = Quaternion.Euler(mouseRotation.eulerAngles.x, 0, 0);
	}
}

This script is very short: it contains two references to two different objects. One of them will have the X rotation modified and the other — the Y rotation.

This class will modify the X rotation of the previously created container for first-person objects like the camera or your player’s hands. The Y rotation is the rotation of the entire body of the player’s prefab, so the reference for this modification will be the player’s root transform game object.

Add the created script to the player’s prefab and complete the missing references:

First-Person Shooter

Look around!

From now on, each of the clients can move individually and look around the scene freely. 👀

First-Person Shooter

In the next part we’ll be preparing weapon abstract class and loadout controller! 🔫🎒

4 - First-Person Shooter: Loadout controller and Weapon abstract class

Tutorial: First-Person Shooter in Unity - Preparing the Loadout controller and Weapon abstract class

This is Elympics First-Person Shooter tutorial: part 4. In this part we’ll be preparing the Loadout Controller and Weapon abstract class. See: Part 3.

Weapon abstract class

In FPS games, players need to be able to shoot using diverse weapons. In this project, you’ll have two types of it: one that shoots projectiles and another one based on raycast.

Start by creating an abstract class for your weapon: Weapon.cs. This class will support some universal behaviors for all the weapons that you’ll be creating.

public abstract class Weapon : ElympicsMonoBehaviour, IInitializable, IUpdatable
{
	[SerializeField] protected float fireRate = 60.0f;
	[SerializeField] private GameObject meshContainer = null;

	protected ElympicsFloat currentTimeBetweenShots = new ElympicsFloat();

	protected float timeBetweenShots = 0.0f;
	public float TimeBetweenShoots => timeBetweenShots;

	protected bool IsReady => currentTimeBetweenShots >= timeBetweenShots;

	public GameObject Owner => this.transform.root.gameObject;

	public void Initialize()
	{
		CalculateTimeBetweenShoots();
	}

	public void CalculateTimeBetweenShoots()
	{
		if (fireRate > 0)
			timeBetweenShots = 60.0f / fireRate;
		else
			timeBetweenShots = 0.0f;
	}

	public void ExecutePrimaryAction()
	{
		ExecuteWeaponActionIfReady();
	}

	private void ExecuteWeaponActionIfReady()
	{
		if (IsReady)
		{
			ProcessWeaponAction();

			currentTimeBetweenShots.Value = 0.0f;
		}
	}

	protected abstract void ProcessWeaponAction();

	public virtual void SetIsActive(bool isActive)
	{
		meshContainer.SetActive(isActive);
	}

	public virtual void ElympicsUpdate()
	{
		if (!IsReady)
		{
			currentTimeBetweenShots.Value += Elympics.TickDuration;
		}
	}
}

Each of your weapons will have a specific fireRate, i.e. number of shots per minute. Its base setting, 60.0f, suggests that the weapon will fire at the rate of one round per second. The mesh container variable will be used to disable the weapon view once the player stops using it.

The script begins its initialization in the Elympics Initialize() method. The only action it performs is to convert the fireRate value to the real time between the successive shots using the CalculateTimeBetweenShots(). The other methods will be called by external classes.

ExecutePrimaryAction() is a method that should be called when the player presses the shot button. It calls another ExecuteWeaponActionIfReady() method that checks whether the time elapsed since the last shot corresponds to what was calculated on the basis of the typed fireRate value. If so, the ProcessWeaponAction abstract method is executed: it contains the firing logic adjusted to a specific type of weapon, e.g. creating and firing a projectile or shooting with a raycast. This condition is controlled by ElympicsFloat currentTimeBetweenShots: a variable that synchronizes its value according to the current state on the server.

The ElympicsUpdate method checks whether the weapon is ready to fire. It increases the currentTimeBetweenShots variable with the TickDuration value.

Loadout controller

Once you prepare the abstract class for your weapons, you can move on to creating another controller: LoadoutController. This class will be responsible for storing the weapons available to the player, setting the equipped one and performing all the actions related to it.

public class LoadoutController : ElympicsMonoBehaviour, IInitializable, IUpdatable
{
	[Header("References:")]
	[SerializeField] private Weapon[] availableWeapons = null;

	[Header("Parameters:")]
	[SerializeField] private float weaponSwapTime = 0.3f;

	public event Action WeaponSwapped = null;

	private ElympicsInt currentEquipedWeaponIndex = new ElympicsInt(0);
	private ElympicsFloat currentWeaponSwapTime = null;

	private Weapon currentEquipedWeapon = null;

	public void Initialize()
	{
		currentWeaponSwapTime = new ElympicsFloat(weaponSwapTime);

		DisableAllWeapons();

		currentEquipedWeaponIndex.ValueChanged += UpdateCurrentEquipedWeaponByIndex;
		UpdateCurrentEquipedWeaponByIndex(currentEquipedWeaponIndex, 0);
	}

	private void DisableAllWeapons()
	{
		foreach (Weapon weapon in availableWeapons)
		weapon.SetIsActive(false);
	}

	public void ProcessLoadoutActions(bool weaponPrimaryAction, int weaponIndex)
	{
		if (weaponIndex != -1 && weaponIndex != currentEquipedWeaponIndex)
		{
			SwitchWeapon(weaponIndex);
		}
		else
		{
			if (currentWeaponSwapTime.Value >= weaponSwapTime)
				ProcessWeaponActions(weaponPrimaryAction);
		}
	}

	private void ProcessWeaponActions(bool weaponPrimaryAction)
	{
		if (weaponPrimaryAction)
		ProcessWeaponPrimaryAction();
	}

	private void ProcessWeaponPrimaryAction()
	{
		currentEquipedWeapon.ExecutePrimaryAction();
	}

	private void SwitchWeapon(int weaponIndex)
	{
		currentEquipedWeaponIndex.Value = weaponIndex;
		currentWeaponSwapTime.Value = 0.0f;
	}

	private void UpdateCurrentEquipedWeaponByIndex(int lastValue, int newValue)
	{
		if (currentEquipedWeapon != null)
			currentEquipedWeapon.SetIsActive(false);

		currentEquipedWeapon = availableWeapons[newValue];
		currentEquipedWeapon.SetIsActive(true);

		WeaponSwapped?.Invoke();
	}

	public void ElympicsUpdate()
	{
		if (currentWeaponSwapTime < weaponSwapTime)
			currentWeaponSwapTime.Value += Elympics.TickDuration;
	}
}

The first variable in your LoadoutController class is an array that holds a reference to all the weapons the player may have. The ability to change weapons will be added later, once you have more than one weapon prefab ready. Another variable is the parameter that defines the time that must elapse after performing the weapon change action to make it usable (while it’s happening, you may e.g. play an appropriate weapon change animation).

In the Initialize() method, assign the appropriate value to the currentWeaponSwapTime variable. This mechanism works similarly to timeBetweenShots in the case of the Weapon class described earlier. Next, disable all the available weapon visuals using the DisableAllWeapons() method and initialize the assignment of the first, default weapon (the first element of the availableWeapons array). The weapon change as a consequence of changing the value of the currentEquipedWeaponIndex variable of the ElympicsInt type. Thanks to it, all of your players will be able to synchronize their weapons and display the one currently used by the given player. The UpdateCurrentEquipedWeaponByIndex method is assigned to change the value of this variable. When executed, it disables the previously used weapon properly and assigns a new value based on the set index from the table.

The LoadoutController class exposes the ProcessLoadoutActions function to external classes. This function takes two arguments: bool weaponPrimaryAction and int weaponIndex. This method will be called by InputController that will provide information whether the player is currently holding the shot button (weaponPrimaryAction) and whether they have pressed the slot change button (weaponIndex).

Changing weapons is checked in the first if statement because it has a higher priority. If the weaponIndex differs from the currently stored index of the active weapon, it is changed (SwitchWeapon). Otherwise, if it’s possible to perform actions on the weapon, the ProcessWeaponActions method is called. Currently, it checks only if weaponPrimaryAction is equal to true. If so, the ProcessWeaponPrimaryAction method is called on the currently selected weapon. This method was described when creating the Weapon.cs class.

Update player inputs

Once you have Loadout Controller, you can immediately add its handling via InputController and InputProvider:

Updated InputProvider.cs:

public class InputProvider : MonoBehaviour
	[...]

	public bool WeaponPrimaryAction { get; private set; }
	public int WeaponSlot { get; private set; }

	private void Update()
	{
		movement.x = Input.GetAxis("Horizontal");
		movement.y = Input.GetAxis("Vertical");

		var mouseX = Input.GetAxis("Mouse X") * (invertedMouseXAxis ? -1 : 1);
		var mouseY = Input.GetAxis("Mouse Y") * (invertedMouseYAxis ? -1 : 1);
		var newMouseAngles = mouseAxis + new Vector3(mouseY, mouseX) * mouseSensivity;
		mouseAxis = FixTooLargeMouseAngles(newMouseAngles);

		Jump = Input.GetButton("Jump");
		WeaponPrimaryAction = Input.GetButton("Fire1");

		WeaponSlot = Input.GetKey(KeyCode.Alpha1) ? 0 :
		Input.GetKey(KeyCode.Alpha2) ? 1 : -1;
	}

	[...]
}

Updated InputController.cs:

[RequireComponent(typeof(InputProvider))]
public class InputController : ElympicsMonoBehaviour, IInputHandler, IInitializable, IUpdatable
{
	[...]
	[SerializeField] private LoadoutController loadoutController = null;

	private void SerializeInput(IInputWriter inputWriter)
	{
		[...]
		inputWriter.Write(inputProvider.Jump);
		inputWriter.Write(inputProvider.WeaponPrimaryAction);
		inputWriter.Write(inputProvider.WeaponSlot);
	}

	public void ElympicsUpdate()
	{
		{
			[...]

			inputReader.Read(out jump);
			inputReader.Read(out bool weaponPrimaryAction);
			inputReader.Read(out int weaponSlot);

			ProcessMouse(Quaternion.Euler(new Vector3(xRotation, yRotation, zRotation)));

			ProcessLoadoutActions(weaponPrimaryAction, weaponSlot);
		}
		
		ProcessMovement(forwardMovement, rightMovement);
	}



	private void ProcessLoadoutActions(bool weaponPrimaryAction, int weaponSlot)
	{
		loadoutController.ProcessLoadoutActions(weaponPrimaryAction, weaponSlot);
	}

	[...]
}

You’ll also have to update the player’s prefab:

First-Person Shooter

It’s not finished yet!

At this point, you’ll be able to shoot from the currently selected weapon by clicking LMB. If you don’t have any weapons yet, go to the next chapter of this tutorial. 🔫

5 - First-Person Shooter: Projectile Weapon

Tutorial: First-Person Shooter in Unity - Projectile Weapon

This is Elympics First-Person Shooter tutorial: part 5. In this part we’ll be creating a weapon that fires projectiles. See: Part 4.

Rocket Launcher

Let’s start by creating a weapon that fires projectiles. The first step is to create a prefab representing our weapon consisting of a container for all the meshes of our weapon as well as an empty game object designating the place where your projectiles will be spawned.

First-Person Shooter

Remove all the colliders (if there are any) from the prefab because you won’t need them.

Add the ElympicsBehaviour component to the parent object in your prefab. Then, go to the script of your weapon and the missile itself: RocketLauncher.cs and ProjectileBullet.cs

Start with a new RocketLauncher script:

public class RocketLauncher : Weapon
{
	[SerializeField] private Transform bulletSpawnPoint = null;
	[SerializeField] private ProjectileBullet bulletPrefab = null;

	public ProjectileBullet BulletPrefab => bulletPrefab;

	protected override void ProcessWeaponAction()
	{
		var bullet = CreateBullet();

		bullet.transform.position = bulletSpawnPoint.position;
		bullet.transform.rotation = bulletSpawnPoint.transform.rotation;
		bullet.GetComponent<ProjectileBullet>().Launch(bulletSpawnPoint.transform.forward);
	}

	private GameObject CreateBullet()
	{
		var bullet = ElympicsInstantiate(bulletPrefab.gameObject.name, ElympicsPlayer.FromIndex(Owner.GetComponent<PlayerData>().PlayerId));
		bullet.GetComponent<ProjectileBullet>().SetOwner(Owner.gameObject.transform.root.gameObject.GetComponent<ElympicsBehaviour>());

		return bullet;
	}
}

The RocketLauncher class has two serialized fields: bulletSpawnPoint and bulletPrefab. BulletSpawnPoint is where the bullet will be fired from. BulletPrefab is the bullet the weapon will fire. It’s a ProjectileBullet type bullet, so it will have its own logic (we’ll describe it in a moment).

The core element of the RocketLauncher class is the overriden ProcessWeaponAction method. The ProcessWeaponAction method is called by another method in the Weapon base class if the weapon is ready to fire.

Please note that in Elympics, instantiating and destroying objects is possible only in the ElympicsUpdate function.

Fortunately, ProcessWeaponAction is called as part of ElympicsUpdate in InputController, so you can fire the projectile right away.

The bullet is instantiated using the CreateBullet method. In this method, ElympicsInstantiate is called and it takes the path to the object in the Resources file as arguments (in this case, the projectile won’t be in any subfolder, so you can simply give its name). The second argument to pass is the id of the player who uses the given weapon. Thanks to this, the instantiated bullet will automatically have a Predictable For for the given player in the ElympicsBehaviour component. In the last line, you call SetOwner and pass your player’s parent ElympicsBehaviour component. The use of this function will be described in the ProjectileBullet class.

Let’s get back to the ProcessWeaponAction function. After creating the projectile, you’ll need to set its position and rotation in accordance with the set spawnPoint, and then call the Launch method, giving the direction of the projectile flight as an argument.

Projectile bullet

The weapon fires bullets with its own logic. An example of such a bullet would be the ProjectileBullet.cs script:

[RequireComponent(typeof(Rigidbody))]
public class ProjectileBullet : ElympicsMonoBehaviour, IUpdatable, IInitializable
{
	[Header("Parameters:")]
	[SerializeField] protected float speed = 5.0f;
	[SerializeField] protected float lifeTime = 5.0f;
	[SerializeField] protected float timeToDestroyOnExplosion = 1.0f;

	[Header("References:")]
	[SerializeField] private ExplosionArea explosionArea = null;
	[SerializeField] private GameObject bulletMeshRoot = null;
	[SerializeField] protected new Rigidbody rigidbody = null;
	[SerializeField] protected new Collider collider = null;

	public float LifeTime => lifeTime;

	protected ElympicsBool readyToLaunchExplosion = new ElympicsBool(false);
	protected ElympicsBool markedAsReadyToDestroy = new ElympicsBool(false);

	protected ElympicsBool colliderEnabled = new ElympicsBool(false);
	protected ElympicsBool bulletExploded = new ElympicsBool(false);

	private ElympicsGameObject owner = new ElympicsGameObject();
	private ElympicsFloat deathTimer = new ElympicsFloat(0.0f);


	public void Initialize()
	{
		colliderEnabled.ValueChanged += UpdateColliderEnabled;
	}

	private void UpdateColliderEnabled(bool lastValue, bool newValue)
	{
		collider.enabled = newValue;
	}

	public void SetOwner(ElympicsBehaviour owner)
	{
		this.owner.Value = owner;
	}

	public void Launch(Vector3 direction)
	{
		rigidbody.useGravity = true;
		rigidbody.isKinematic = false;
		colliderEnabled.Value = true;

		ChangeBulletVelocity(direction);
	}

	private void ChangeBulletVelocity(Vector3 direction)
	{
		rigidbody.velocity = direction * speed;
	}

	private void OnCollisionEnter(Collision collision)
	{
		if (owner.Value == null)
			return;

		if (collision.transform.root.gameObject == owner.Value.gameObject)
			return;

		DetonateProjectile();
	}

	private IEnumerator SelfDestoryTimer(float time)
	{
		yield return new WaitForSeconds(time);

		DestroyProjectile();
	}

	private void DestroyProjectile()
	{
		markedAsReadyToDestroy.Value = true;
	}

	private void DetonateProjectile()
	{
		readyToLaunchExplosion.Value = true;
	}

	public void ElympicsUpdate()
	{
		if (readyToLaunchExplosion.Value && !bulletExploded)
			LaunchExplosion();
		if (markedAsReadyToDestroy.Value)
			ElympicsDestroy(this.gameObject);

		deathTimer.Value += Elympics.TickDuration;

		if ((!bulletExploded && deathTimer >= lifeTime)
		|| (bulletExploded && deathTimer >= timeToDestroyOnExplosion))
		{
			DestroyProjectile();
		}
	}

	private void LaunchExplosion()
	{
		bulletMeshRoot.SetActive(false);
		rigidbody.isKinematic = true;
		rigidbody.useGravity = false;
		colliderEnabled.Value = false;

		explosionArea.Detonate();

		bulletExploded.Value = true;
		deathTimer.Value = 0.0f;
	}
}

The main assumptions of this ProjectileBullet script are defined by the first three variables: The projectile has a specific speed and lifetime after which it will automatically detonate. As a result of the explosion, the projectile still exists in the game world for a second so that you can recreate the appropriate feedback of the explosion (e.g. particles).

The references this script needs are:

  • Explosion Area: a separate class that defines the behavior of the projectile when it explodes. All in all it’s mean to deal damage to players;
  • BulletMeshRoot: a reference to the main container of the bullet that contains all the meshes. We don’t want the projectile to show up in the game scene while exploding;
  • Rigidbody and Collider: to manage and synchronize the state of these two components respectively.

This class also uses many ElympicsVars:

  • readyToLaunchExplosion, markedAsReadyToDestroy and bulletExploded: ElympicsBools used to help synchronize the current state of the object;
  • colliderEnabled: a variable that helps to synchronize the collider state;
  • Owner: the player who fired the projectile;
  • deathTimer: the current time counted down to control the state changed as a result of the passage of time, e.g. Lifetime.

The main method of initiating the projectile’s operation is Launch() - where the parameters are set and velocity is assigned.

The missile explodes when it collides (OnCollisionEnter) with another object other than its owner (set while creating this object in the RocketLauncher class using the SetOwner method). The DetonateProjectile method is called, changing the readyToLaunchExplosion synchronized flag to true. This flag is checked in ElympicsUpdate and if the conditions are met, the projectile explodes by calling the LaunchExplosion method.

This method stops the rigidbody and disables collisions, but its main function is to call the ExplosionArea object’s Detonate() function (ExplosionArea will be described later in this chapter). ExplosionArea’s main task is to detect players within its firing range and deal damage to them. At the end, further flags that don’t allow the bullet to explode again are set (bulletExploded), and the timer responsible for tracking the bullet’s lifetime is reset. If the projectile didn’t hit any other object and its lifetime expired or it exploded and its lifetime expired, it’s destroyed after the explosion (ElympicsDestroy).

if ((!bulletExploded && deathTimer >= lifeTime)
|| (bulletExploded && deathTimer >= timeToDestroyOnExplosion))
{
	DestroyProjectile();
}

The last element necessary for the proper functioning of your weapon is the explosion. The projectile’s job is to move and detect a collision with an object, while an explosion is triggered on impact to deal damage to targets within its range.

Explosion area

Here’s an example implementation of ExplosionArea.cs:

public class ExplosionArea : ElympicsMonoBehaviour
{
	[Header("Parameters:")]
	[SerializeField] private float explosionDamage = 10.0f;
	[SerializeField] private float explosionRange = 2.0f;

	[Header("References:")]
	[SerializeField] private ParticleSystem explosionPS = null;
	[SerializeField] private ElympicsMonoBehaviour bulletOwner = null;

	public void Detonate()
	{
		DetectTargetsInExplosionRange();

		explosionPS.Play();
	}

	private void DetectTargetsInExplosionRange()
	{
		Collider[] objectsInExplosionRange = Physics.OverlapSphere(this.transform.position, explosionRange);

		foreach (Collider objectInExplosionRange in objectsInExplosionRange)
		{
			if (TargetIsNotBehindObstacle(objectInExplosionRange.transform.root.gameObject))
				TryToApplyDamageToTarget(objectInExplosionRange.transform.root.gameObject);
		}
	}

	private void TryToApplyDamageToTarget(GameObject objectInExplosionRange)
	{
		//Damage to apply here!
		Debug.Log("Apply damage to: " + objectInExplosionRange.gameObject.name);
	}

	private bool TargetIsNotBehindObstacle(GameObject objectInExplosionRange)
	{
		var directionToObjectInExplosionRange = (objectInExplosionRange.transform.position - this.transform.position).normalized;

		if (Physics.Raycast(this.transform.position, directionToObjectInExplosionRange, out RaycastHit hit, explosionRange))
		{
			return hit.transform.gameObject == objectInExplosionRange;
		}

		return false;
	}
}

The main parameters describing this object are explosionDamage and explosionRange as well as the following references:

  • bulletOwner: useful when you want to know who was the character dealing damage;
  • explosionPS: a particle system recreated when calling the initializing Detonate() method (not required).

During the initialization of the explosion (i.e. when calling the Detonate method) all objects within the explosion range are checked and saved (objectsInExplosionRange). Then, for each object, Explosion Area checks if the potential target is behind an obstacle that should block the damage. It’s an overly simplified implementation, but it’s fully sufficient for the needs of our project.

If the target is not hiding behind any obstacle, the last step is to try to apply damage (TryToApplyDamage). In this method, we should try to get a component responsible for managing statistics, but leave it empty for now because you don’t have such a component yet.

At this point, you should have a complete, functional bullet projectile weapon system, so let’s create appropriate prefabs and assign references to be able to fully test your project.

Prefabs and references

Start by creating an ExplosionArea. Create a new empty game object and add an ElympicsBehaviour and an ExplosionArea component to it. In the case of ExplosionArea, it’s also worth adding the object transform synchronization (Add Transform Synchronization). You can also optionally add a particle system to it to be called when the explosion is initiated.

First-Person Shooter

Then, create a prefab from this object.

You’ll use the ExplosionArea prepared in this way to prepare the ProjectileBullet. Create a new EmptyGameObject in the scene and add the previously created ExplosionArea prefab and another EmptyGameObject to it. It’ll be a container for all the meshes related to the projectile.

First-Person Shooter

Add the following components to the prepared RocketLauncherBullet object:

  • ElympicsBehaviour (and Add Rigidbody Synchronization with Synchronize _useGravity and Synchronize _isKinematic checked);
  • Rigidbody (Interpolate: interpolate, Collision Detection: Continous);
  • Collider (in this case, capsule collider);
  • Previously created Projectile Bullet script.

Assign appropriate references to the ProjectileBullet script. The projectile object should look like this:

First-Person Shooter

Create a prefab from this object and place it in the Resources folder: it’s very important, because if you place this prefab in a different folder, you won’t be able to instantiate it using the ElympicsInstantiate method!

First-Person Shooter

With the projectileBullet and explosionArea prefabs ready, you can proceed to the full preparation of your weapons. Complete the previously created prefab of your weapons with appropriate scripts and references:

First-Person Shooter

Now, go to the player prefab. Find the FirstPersonViewContainer component and add your RocketLauncher prefab to it. Modify its position accordingly so that the view from the camera corresponds to the view from the FPS game (the weapon visible in the bottom right corner of the screen).

First-Person Shooter

Also, make sure that the ElympicsBehaviour component in the RocketLauncher object will have the predictable for value set appropriately depending on the player prefab you’re currently modifying.

First-Person Shooter

The last step is to find the LoadoutController component in the player’s parent object and assign your RocketLauncher to the currently available weapons.

First-Person Shooter

From now on, you’ll be able to fire your weapon, and the projectile explosion will be ready to deal damage!

From now on you can fire projectiles!

First-Person Shooter First-Person Shooter

Players can now fire projectiles by clicking LMB! In the next part we’ll handle dealing damage and processing player’s death! 🩹💀

6 - First-Person Shooter: Dealing damage

Tutorial: First-Person Shooter in Unity - Dealing damage and handling player’s death

This is Elympics First-Person Shooter tutorial: part 6. In this part we’ll be handling player’s death. See: Part 5.

Stats Controller

There’s no component responsible for controlling the health of your players yet, so let’s add StatsController that serves this function.

The first step is to create a new script, StatsController.cs:

public class StatsController : ElympicsMonoBehaviour, IInitializable
{
	[Header("Parameters:")]
	[SerializeField] private float maxHealth = 100.0f;

	[Header("References:")]
	[SerializeField] private DeathController deathController = null;

	private ElympicsFloat health = new ElympicsFloat(0);
	public event Action<float, float> HealthValueChanged = null;

	public void Initialize()
	{
		health.Value = maxHealth;
		health.ValueChanged += OnHealthValueChanged;

		deathController.PlayerRespawned += ResetPlayerStats;
	}

	private void ResetPlayerStats()
	{
		health.Value = maxHealth;
	}

	public void ChangeHealth(float value, int damageOwner)
	{
		if (!Elympics.IsServer || deathController.IsDead)
			return;

		health.Value += value;

		if (health.Value <= 0.0f)
			deathController.ProcessPlayersDeath(damageOwner);
	}

	private void OnHealthValueChanged(float lastValue, float newValue)
	{
		HealthValueChanged?.Invoke(newValue, maxHealth);
	}
}

At the start of the game, this component assigns the maximum possible health state determined by the maxHealth variable to the player. The player’s life points are a key element of the game, which is why they’re strictly controlled and synchronized by the server (ElympicsFloat health).

The most important method of this class is ChangeHealth. This method is responsible for changing the player’s health, which can be done only by the server to avoid problems with bad prediction on the client side. Such problems would result in a large number of subsequent handling steps, e.g. player death. The health variable is of the ElympicsVar type, so although modifications are made on the server, all other clients will be able to synchronize its value.

If the hitpoints fall below zero, the DeathController method will be called. It’s another component responsible for handling the player’s death. The DeathController should also provide information about the current state of the IsDead player (whether they’re alive or not) to avoid modifying hitpoints in the death state.

public class DeathController : ElympicsMonoBehaviour, IUpdatable
{
	[Header("Parameters:")]
	[SerializeField] private float deathTime = 2.0f;

	public ElympicsBool IsDead { get; } = new ElympicsBool(false);
	public ElympicsFloat CurrentDeathTime { get; } = new ElympicsFloat(0.0f);

	public event Action PlayerRespawned = null;
	public event Action<int, int> HasBeenKilled = null;


	private PlayerData playerData = null;

	private void Awake()
	{
		playerData = GetComponent<PlayerData>();
	}

	public void ProcessPlayersDeath(int damageOwner)
	{
		CurrentDeathTime.Value = deathTime;
		IsDead.Value = true;

		Debug.Log(this.gameObject.name + " has been killed!");

		HasBeenKilled?.Invoke((int)PredictableFor, damageOwner);
	}

	public void ElympicsUpdate()
	{
		if (!IsDead || !Elympics.IsServer)
			return;

		CurrentDeathTime.Value -= Elympics.TickDuration;

		if (CurrentDeathTime.Value <= 0)
		{
			//Respawn player
			IsDead.Value = false;
		}
	}
}

The DeathController.cs component is used only to control the player’s death status. Its main parameter is the time of death: the time after which the player should be able to respawn.

The main method of this component is ProcessPlayersDeath called by StatsController when a player’s hitpoints drop below 0. The argument to this method is the id of the player who contributed to the death of this player. This argument is especially useful when monitoring player stats for kills and deaths. After calling the ProcessPlayersDeath function, the IsDead flag is set to true, and the timer starts counting down to the player’s respawn.

Add both of these components to your player prefab:

First-Person Shooter

The final step to allow your projectiles to damage the player is to modify the TryToApplyDamageToTarget method in the xplosionArea component:

Updated ExplosionArea.cs:

    [...]
    private void TryToApplyDamageToTarget(GameObject objectInExplosionRange)
    {
		if (objectInExplosionRange.TryGetComponent<StatsController>(out StatsController targetStatsController))
		{
			targetStatsController.ChangeHealth(-explosionDamage, (int)bulletOwner.PredictableFor);
		}
    }

Check whether the object that was in the blast field contains the StatsController component. If so, modify its value.

From now on the player can be killed!

Explosions from projectile bullets will now deal damage to players!

First-Person Shooter

In the next part we’ll create another weapon based on using raycasts! 🔫↗️

7 - First-Person Shooter: Raycast gun

Tutorial: First-Person Shooter in Unity - Raycast gun

This is Elympics First-Person Shooter tutorial: part 7. In this part we’ll be creating raycast gun. See: Part 6.

Raycast gun

The second weapon type to implement is a raycast-damage gun. Just like RocketLauncher, it will inherit from the Weapon class, but due to the use of raycast, it will not require additional scripts such as bullet or explosion area.

Let’s go straight to the implementation:

public class RailGun : Weapon
{
	[Header("Parameters:")]
	[SerializeField] private float loadingTime = 1.0f;
	[SerializeField] private float damage = 50.0f;

	[Header("References:")]
	[SerializeField] private new Camera camera = null;

	private ElympicsFloat currentLoadingTime = new ElympicsFloat(0.0f);
	private ElympicsBool isLoadingToShot = new ElympicsBool(false);

	public event Action<float, float> LoadingTimeChanged;
	public event Action<RaycastHit> WeaponFired;

	public override void Initialize()
	{
		base.Initialize();

		currentLoadingTime.ValueChanged += HandleCurrentLoadingTimeChanged;
	}

	protected override void ProcessWeaponAction()
	{
		if (isLoadingToShot)
			return;

		currentLoadingTime.Value = 0.0f;
		isLoadingToShot.Value = true;
	}

	public override void ElympicsUpdate()
	{
		base.ElympicsUpdate();

		if (isLoadingToShot)
		{
			if (currentLoadingTime.Value >= loadingTime)
			{
				ChangeCurrentLoadingTime(0.0f);
				isLoadingToShot.Value = false;
			}
			else
			{
				ChangeCurrentLoadingTime(currentLoadingTime.Value + Elympics.TickDuration);
			}
		}
	}


	private void HandleCurrentLoadingTimeChanged(float lastValue, float newValue)
	{
		if (lastValue >= loadingTime && newValue < loadingTime)
			ProcessRayShot();
	}

	private void ProcessRayShot()
	{
		RaycastHit hit;

		if (Physics.Raycast(camera.transform.position, camera.transform.forward, out hit, Mathf.Infinity))
		{
			if (hit.transform.TryGetComponent<StatsController>(out StatsController statsController))
			{
				statsController.ChangeHealth(-damage, (int)PredictableFor);
			}
		}

		WeaponFired?.Invoke(hit);

		ChangeCurrentLoadingTime(0.0f);
		isLoadingToShot.Value = false;
	}

	public override void SetIsActive(bool isActive)
	{
		base.SetIsActive(isActive);

		if (!isActive)
			isLoadingToShot.Value = false;

		ChangeCurrentLoadingTime(0.0f);
	}

	private void ChangeCurrentLoadingTime(float newCurrentLoadingTime)
	{
		currentLoadingTime.Value = newCurrentLoadingTime;
		LoadingTimeChanged?.Invoke(currentLoadingTime, loadingTime);
	}
}

The parameters that determine the weapon are the damage it deals after hitting the player with a raycast and the recharge time. Unlike the RocketLauncher, this weapon won’t fire immediately after a click but will start the loading operation and will only fire after a certain amount of time. To be able to use raycasts precisely, this component needs a reference to the player’s main camera.

Calling the main method through ProcessWeaponAction first checks if the weapon is being loaded. If not - the timer responsible for loading the weapon is reset and a flag is set that determines the loading status of the weapon.

ElympicsUpdate checks if the timer has reached the value needed for the weapon to fire. If so, the ProcessRayShot method is called.

The ProcessRayShot method is responsible for using the raycast and checking whether it hit the object with the StatsController component - if so, the ChangeHealth method is called on it, whose argument is the previously defined damage value. Finally, all variables are reset and the weapon is ready to be used again.

Having the raycast gun class prepared we can create a raycast gun prefab. For this purpose, as in the case of RocketLauncher, we create an Empty Game Object and immediately add another Empty Game Object to it, which will be a container and a mesh.

First-Person Shooter

Next, we add the previously created script to the parent object “RailGun” and create a prefab from this object.

First-Person Shooter

Then we add the prepared prefab to the FirstPersonViewContainer component in the player’s prefab, positioning it appropriately and adding a reference to the player’s camera.

First-Person Shooter

We also add prefab to the list of available weapons in the LoadoutController in the parent player object.

First-Person Shooter

Let’s try a new weapon!

From now on, players can use both bullet projectile weapons and raycast weapons!

First-Person Shooter

In the next part we’ll focus on player’s respawn! 💀➞🕺

8 - First-Person Shooter: Player’s respawn

Tutorial: First-Person Shooter in Unity - Player’s respawn

This is Elympics First-Person Shooter tutorial: part 8. In this part we’ll be handling player’s respawn. See: Part 7.

Players provider class

Players can damage each other with different types of weapons, but to create a full gameplay loop, you need to be able to respawn them. To do this, you’ll have to create a PlayerSpawner object that will be responsible for placing players in the appropriate positions at the start of the game and resetting them after their death.

Before moving on to the proper implementation, let’s look at the assumptions for our PlayersSpawner:

  • it has several positions (spawn points) defining where player can be moved to;
  • it’s executed at the start of the game (assigns each player a starting position) and it also assigns a new starting position to a given player on the DeathController’s request (once the player’s respawn time has expired);
  • it’s performed only on the server (it’s related to the randomization of each spawn point).

The PlayerSpawner will be a script placed on a separate Empty Game Object in the game scene. To allow it to access information about all the players in the game, you’ll have to create a special PlayersProvider class that will provide a reference to all the players in the scene as well as a reference to the player currently controlled by the client. Thanks to this solution, you won’t have to manually assign references to each player (this way, you eliminate the risk of error resulting from e.g. a missing reference), and you’ll also be able to universally use this class in other scripts that will focus on controlling the game and will require information about e.g. individual players.

So, create a new PlayersProvider.cs class:

public class PlayersProvider : ElympicsMonoBehaviour, IInitializable
{
	public PlayerData ClientPlayer { get; private set; } = null;
	public PlayerData[] AllPlayersInScene { get; private set; } = null;

	public bool IsReady { get; private set; } = false;
	public event Action IsReadyChanged = null;

	public void Initialize()
	{
		FindAllPlayersInScene();
		FindClientPlayerInScene();
		IsReady = true;
		IsReadyChanged?.Invoke();
	}

	private void FindAllPlayersInScene()
	{
		this.AllPlayersInScene = FindObjectsOfType<PlayerData>().OrderBy(x => x.PlayerId).ToArray();
	}

	private void FindClientPlayerInScene()
	{
		foreach (PlayerData player in AllPlayersInScene)
		{
			if ((int)Elympics.Player == player.PlayerId)
			{
				ClientPlayer = player;
				return;
			}
		}

		//Fix for server side.
		ClientPlayer = AllPlayersInScene[0];
	}

	public PlayerData GetPlayerById(int id)
	{
		return AllPlayersInScene.FirstOrDefault(x => x.PlayerId == id);
	}
}

This class has a reference to all the players in the scene, as well as to the specific player controlled by the client in the game. The Initialize method searches for all player objects in the scene using FindObjectsOfType<PlayerData>().

The PlayersProvider class also has an appropriate IsReady variable as well as an event related to this variable, so that other classes that want to use PlayersProvider in the Initialize method can be sure that all the references are properly fetched and assigned.

While iterating through all the player references found in FindAllPlayersInScene, the FindClientPlayerInScene method compares and saves the player currently controlled by the client. For the server, ClientPlayer will always be null because the server side doesn’t have a valid value for Elympics.Player. For this reason, at the end of the method, if no player matching the ClientPlayer variable is found, it’s assigned the first player in the scene.

Spawner

Once you create the PlayersProvider class, you can move on to creating the proper spawner class:

public class PlayersSpawner : ElympicsMonoBehaviour, IInitializable
{
	[SerializeField] private PlayersProvider playersProvider = null;
	[SerializeField] private Transform[] spawnPoints = null;

	private System.Random random = null;

	public static PlayersSpawner Instance = null;

	private void Awake()
	{
		if (PlayersSpawner.Instance == null)
			PlayersSpawner.Instance = this;
		else
			Destroy(this);
	}

	public void Initialize()
	{
		if (!Elympics.IsServer)
			return;

		random = new System.Random();

		if (playersProvider.IsReady)
			InitialSpawnPlayers();
		else
			playersProvider.IsReadyChanged += InitialSpawnPlayers;
	}

	private void InitialSpawnPlayers()
	{
		var preparedSpawnPoints = GetRandomizedSpawnPoints().Take(playersProvider.AllPlayersInScene.Length).ToArray();

		for (int i = 0; i < playersProvider.AllPlayersInScene.Length; i++)
		{
			playersProvider.AllPlayersInScene[i].transform.position = preparedSpawnPoints[i].position;
		}
	}

	public void SpawnPlayer(PlayerData player)
	{
		Vector3 spawnPoint = GetSpawnPointWithoutPlayersInRange().position;

		player.transform.position = spawnPoint;
	}

	private Transform GetSpawnPointWithoutPlayersInRange()
	{
		var randomizedSpawnPoints = GetRandomizedSpawnPoints();
		Transform chosenSpawnPoint = null;

		foreach (Transform spawnPoint in randomizedSpawnPoints)
		{
			chosenSpawnPoint = spawnPoint;

			Collider[] objectsInRange = Physics.OverlapSphere(chosenSpawnPoint.position, 3.0f);

			if (!objectsInRange.Any(x => x.transform.root.gameObject.TryGetComponent<PlayerData>(out _)))
				break;
		}

		return chosenSpawnPoint;
	}

	private IOrderedEnumerable<Transform> GetRandomizedSpawnPoints()
	{
		return spawnPoints.OrderBy(x => random.Next());
	}
}

The class needs the following references to work properly:

  • PlayersProvider which will give it access and information about all the players in the game;
  • Table of Transform[] spawn points which is the positions that will be assigned to players during spawn / respawn.

You’ll also need to store a System.Random reference that you use when selecting a new random spawn point.

The PlayersSpawner class is a singleton (Awake method) due to the fact that selected part of its code will be called by DeathController.cs of the individual players.

In the Initialize function, you start by checking whether your game is a server (according to the previously saved assumptions). Depending on whether PlayersProvider is already initialized, you call the InitialSpawnPlayers method. If not, you execute it by subscription once PlayersProvider is ready.

The InitialSpawnPlayers function selects as many random spawn points as there are players in the scene using the GetRandomizedSpawnPoints method (this method returns all the available spawn points in random order). Then, each player’s position (transform.position) is changed to the position of their spawn point. Thanks to it, at the beginning of the game, each player will start the game in a different, random place. In the case of this method, it’s not necessary to perform any additional actions: as this method is performed at the start of the game, we’re sure that there will be no other player near the drawn spawn point.

The situation is different when you want to reset the player’s position after their death: the drawn point may already be occupied by another player during the game. For this reason, in the SpawnPlayer method (called ultimately by DeathController.cs), you’ll need to use the GetSpawnPointWithoutPlayersInRange method that returns a random spawn point after making sure that there’s no other player in its given area. The SpawnPlayer method itself works very similarly to the InitialSpawnPlayers method except that it assigns a spawn point to only one player passed as its PlayerData argument.

Once you prepare the scripts, you can proceed to attaching them to your scene. You’ll need to create an Empty Game Object with the PlayersProvider script in it:

First-Person Shooter

Then, add another Empty Game Object and add the PlayersSpawner script to it:

First-Person Shooter

For the above script to work properly, you’ll need to supplement it with appropriate references. Drag the PlayersProvider object and create several Empty Game Objects that will act as Spawn Points.

First-Person Shooter First-Person Shooter

The last step is to modify the DeathController.cs script so that the player will be respawned once a certain amount of time has elapsed after their death:

[...]
	public void ElympicsUpdate()
	{
		if (!IsDead || !Elympics.IsServer)
			return;

		CurrentDeathTime.Value -= Elympics.TickDuration;

		if (CurrentDeathTime.Value <= 0)
		{
			RespawnPlayer();
		}
	}

	private void RespawnPlayer()
	{
		PlayersSpawner.Instance.SpawnPlayer(playerData);
		PlayerRespawned?.Invoke();
		IsDead.Value = false;
	}

It’s deathmatch with no score limit!

From now on, your players will start the game from a random spawn point, and, after their death, they will be properly moved and respawn at a random spawn point.

First-Person Shooter

In the next part we’ll create health bar and death screen ❤️❤️🖤

9 - First-Person Shooter: Health bar

Tutorial: First-Person Shooter in Unity - HP Bar, Death screen

This is Elympics First-Person Shooter tutorial: part 9. In this part we’ll be creating hp bar and death screen. See: Part 8.

Health bar

Your players need to fully understand what’s happening in the game, so the next step is to add several UI elements to it: the HP bar and the screen warning them about a possible death.

Let’s start with the HP bar. Add the Canvas component and the Event System to the scene and create a simple slider that will act as a bar with life points.

First-Person Shooter First-Person Shooter

To make your HP bar fully functional, you only need a script that will properly modify the “fill” value of the slider depending on the current state of health of the player.

To do this, create another new HealthBar.cs script:

public class HealthBar : MonoBehaviour
{
	[SerializeField] private PlayersProvider playersProvider = null;
	[SerializeField] private Slider healthSlider = null;

	private void Start()
	{
		if (playersProvider.IsReady)
			SubscribeToStatsController();
		else
			playersProvider.IsReadyChanged += SubscribeToStatsController;
	}

	private void SubscribeToStatsController()
	{
		var clientPlayerData = playersProvider.ClientPlayer;
		clientPlayerData.GetComponent<StatsController>().HealthValueChanged += UpdateHealthView;
	}

	private void UpdateHealthView(float currentHealth, float maxHealth)
	{
		healthSlider.value = currentHealth / maxHealth;
	}
}

This script will use the previously created PlayersProvider to get a reference to the player controlled by the client thanks to which you’ll be able to modify the slider depending on the value of that correct player’s life points.

This class needs only references to the aforementioned PlayersProvider as well as to the Slider it will modify. The script itself doesn’t need to synchronize with the state on the server because for each client, it will display values for a different player.

As in the case of PlayersSpawner, in the Start method, you’ll need to make sure that PlayersProvider is properly initialized, and then, depending on the result, the SubscribeToStatsController method is executed. In this method, you obtain a reference to the player currently controlled by the client using PlayersProvier, and then, using GetComponent, you obtain a reference to the StatsController component.

While creating the StatsController class, you’ve already created a local event that is executed every time a player’s hit points are modified. This event provides information about both the player’s current health and its maximum possible level, thanks to which we can easily get a percentage of the current health. We assign the UpdateHealthView method to this event, the only task of which is to calculate the percentage of health (in the range of 0.0-1.0), and change the value of the “value” field in the slider responsible for displaying the current health status.

Now, just add the created script to the Slider in the scene and fill it with the required references:

First-Person Shooter

From now on, the HealthBar will track the health values of the player controlled by the client and display its value on the screen in the form of a slider.

Death screen

In order to fully understand the current situation in the game, you still have to create the death screen that will be displayed when the player is dead.

Start by creating an empty object on the Canvas that will be your death screen:

First-Person Shooter

To display the remaining amount of time until respawn on the death screen, add a TextField at the bottom of the screen. You’ll be showing the entire DeathScreen object by changing the alpha value in the CanvasGroup component.

First-Person Shooter

An object prepared in this way must also have an appropriate script that will control it, so you’ll need to create a new class DeathScreen.cs:

public class DeathScreen : MonoBehaviour
{
	[SerializeField] private PlayersProvider playersProvider = null;
	[SerializeField] private CanvasGroup canvasGroup = null;
	[SerializeField] private TextMeshProUGUI deathTimerText = null;

	private void Start()
	{
		if (playersProvider.IsReady)
			SubscribeToDeathController();
		else
			playersProvider.IsReadyChanged += SubscribeToDeathController;
	}

	private void SubscribeToDeathController()
	{
		var clientPlayerDeathController = playersProvider.ClientPlayer.GetComponent<DeathController>();
		clientPlayerDeathController.CurrentDeathTime.ValueChanged += UpdateDeathTimerView;
		clientPlayerDeathController.IsDead.ValueChanged += UpdateDeathScreenView;
	}

	private void UpdateDeathScreenView(bool lastValue, bool newValue)
	{
		canvasGroup.alpha = newValue ? 1.0f : 0.0f;
	}

	private void UpdateDeathTimerView(float lastValue, float newValue)
	{
		deathTimerText.text = Mathf.Ceil(newValue).ToString();
	}
}

Just like the previously created HealthBar.cs script, DeathScreen.cs also requires a reference to the PlayersProvider.cs class to be able to subscribe to the DeathController component of the player controlled by the client. In addition, we also need a reference to the CanvasGroup component to display the entire object each time the player is dead, and a reference to the textbox where we will display the remaining respawn time.

In the Start() method, the function initializing the entire script is called SubscribeToDeathController(). In this method, with the help of PlayersProvider, we get the player controlled by the client and then “pull” the DeathController component from it. This component provides the appropriate ElympicsVars and their ValueChanged events, thanks to which you’ll control the operation of the death screen:

  • Using the variable IsDead, you enable or disable the display of the entire object (UpdateDeathScreenView);
  • Using CurrentDeathTime, you receive information each time the respawn timer value changes. You display this information by rounding the float value using Ceil() - UpdateDeathTimerView.

The script prepared this way is already fully functional. The last step is to simply add it to the object of your death screen and complete the references:

First-Person Shooter

You are dead!

From now on, every time a player dies, they will see a screen informing them about their death and the time remaining until respawning.

First-Person Shooter

In the next part we’ll be controling the gameplay by managing players’ scores 💯

10 - First-Person Shooter: Players’ scores

Tutorial: First-Person Shooter in Unity - Controlling the gameplay: Players’ scores

This is Elympics First-Person Shooter tutorial: part 10. In this part we’ll be controling the gameplay by managing players’ scores. See: Part 9.

The gameplay you’ve created in your project will last forever: players will fight with each other, and when one of them dies, they’ll respawn after a given time. The next step is to add a script that will control the game, i.e. track the statistics of the players and end the match when one of them scores a defined number of points.

Player Scores Manager

Start by creating a new class, PlayersScoresManager.cs, which will be responsible for the gameplay aspects described above.

public class PlayerScoresManager : ElympicsMonoBehaviour, IInitializable
{
	[SerializeField] private PlayersProvider playersProvider = null;
	[SerializeField] private int pointsRequiredToWin = 10;

	private ElympicsArray<ElympicsInt> playerScores = null;
	public ElympicsInt WinnerPlayerId { get; } = new ElympicsInt(-1);

	public bool IsReady { get; private set; } = false;
	public event Action IsReadyChanged = null;

	public void Initialize()
	{
		if (playersProvider.IsReady)
			SetupManager();
		else
			playersProvider.IsReadyChanged += SetupManager;
	}

	private void SetupManager()
	{
		PreparePlayerScores();
		SubscribeToDeathControllers();

		IsReady = true;
		IsReadyChanged?.Invoke();
	}

	private void SubscribeToDeathControllers()
	{
		foreach (PlayerData playerData in playersProvider.AllPlayersInScene)
		{
			if (playerData.TryGetComponent(out DeathController deathController))
			{
				deathController.HasBeenKilled += ProcessPlayerDeath;
			}
		}
	}

	private void ProcessPlayerDeath(int victim, int killer)
	{
		if (victim == killer)
			playerScores.Values[killer].Value--;
		else
			playerScores.Values[killer].Value++;

		if (Elympics.IsServer && playerScores.Values[killer].Value >= pointsRequiredToWin)
		{
			WinnerPlayerId.Value = killer;
		}
	}

	private void PreparePlayerScores()
	{
		var numberOfPlayers = playersProvider.AllPlayersInScene.Length;

		ElympicsInt[] localPlayerScoresArray = new ElympicsInt[numberOfPlayers];

		for (int i = 0; i < numberOfPlayers; i++)
		{
			localPlayerScoresArray[i] = new ElympicsInt(0);
		}

		playerScores = new ElympicsArray<ElympicsInt>(localPlayerScoresArray);
	}
}

The task of this class is to control and manage the points of each player. To get information about all of them, you’ll need references to PlayersProvider once again. In addition, you’ll need to specify the maximum score leading to the end of the match. You’ll be able to modify in the editor.

The scores (ElympicsInt type) of individual players are stored in a synchronized ElympicsArray. This class also stores information about the id of the player who won the game. Thanks to the use of the ElympicsInt synchronized variable, in the future, the components responsible for displaying the summary screen will be able to use ValueChanged to display information about the winner.

Just like in the case of the PlayersProvider class, this class also has the appropriate IsReady flag and provides an event so that other classes that want to use PlayerScoresManager.cs can be sure that the script is fully initialized.

At the start of the game, in the initialize method, the SetupManager() method which requires a fully initialized PlayersProvider class is properly executed. The SetupManager() method prepares the entire manager accordingly:

First, the PreparePlayerScores() method is called to fully initialize the synchronized playerScores table.

Then, the SubscribeToDeathControllers() method is called. In this method, you iterate over all the players in the scene to be able to assign your manager’s main method, ProcessPlayerDeath, to the local event of each player’s DeathController component.

It is the ProcessPlayerDeath method that is the main pillar of your manager. It takes two arguments acting as the id of the players: the first id belonging to the defeated player and the second being the id of the player who scored the point.

Before you increase the score of a specific player, you’ll need to check whether the received arguments have the same value. If that’s the case, it means that the player self-inflicted damage leading to their death. Such a case should not generate a point. On the contrary: if the player causes their own death, they lose a point.

After modifying the points of this player, you check whether this player has reached the amount needed to end the game (in the case of increasing the points value). To avoid errors caused by incorrect prediction, this action is performed only on the server. If the player reaches the required number of points, their id is saved to the WinnerPlayerId variable, and clients synchronize this value with the next snapshot they receive.

Just like in the case of PlayersProvider and PlayersSpawner, the last step is to create an Empty Game Object in the scene and add the newly created script to it with references:

First-Person Shooter

If you want to test whether your class works correctly in its current state, you can add a simple Debug.Log to your code that will write on the server information about a possible modification of the WinnerPlayerId variable:

PlayersScoresManager.cs:

if (Elympics.IsServer && playerScores.Values[killer].Value >= pointsRequiredToWin)
{
	WinnerPlayerId.Value = killer;
	Debug.Log("Winner: " + WinnerPlayerId);
}

And the winner is…

After defeating one of the players a certain number of times, you should see the following message on the console:

First-Person Shooter

Now, your game monitors the gameplay. The script you’ve created will be a good starting point for subsequent scripts responsible for displaying the scoreboard and the proper end of the match.

In the next part we’ll expand controling the gameplay by adding start and end of the match. 🔧

11 - First-Person Shooter: Start and end of the match

Tutorial: First-Person Shooter in Unity - Start and end of the match

This is Elympics First-Person Shooter tutorial: part 11. In this part we’ll expand controling the gameplay by adding start and end of the match. See: Part 10.

Once you have a script that controls the points scored by your players, you can write a suitable controller that will display a summary screen informing all clients about the result of the match.

States of the game

To make the gameplay even smoother, you can also add a start screen that will display the time remaining until the start of the game.

The implementation of the above assumptions will require dividing the game into three states:

  • Initiation of the match;
  • Match proper (Gameplay);
  • End of the match.

During Match Initiation, players will see the start screen. Once the countdown to the start is over, the game will change its state to the proper match in which clients can control their players and fight among themselves. When one of the players scores the number of points required to win the game, the state of the game will change to the last state: End of the game. In this state, you should display a screen informing your clients that the match is over and who turned out to be the winner.

You can describe the current state of the game by creating the appropriate GameState enum:

public enum GameState
{
	Prematch = 0,
	GameplayMatchRunning,
	MatchEnded
}

Game state controller

Then, you can create a controller that will manage the current state of the game:

public class GameStateController : ElympicsMonoBehaviour, IInitializable
{
	[Header("References:")]
	[SerializeField] private PlayerScoresManager playerScoresManager = null;

	public ElympicsInt CurrentGameState { get; } = new ElympicsInt((int)GameState.Prematch);

	public void Initialize()
	{

	}

	private void ChangeGameState(GameState newGameState)
	{
		CurrentGameState.Value = (int)newGameState;
	}

	public void ElympicsUpdate()
	{
		if (playerScoresManager.WinnerPlayerId >= 0 && (GameState)CurrentGameState.Value == GameState.GameplayMatchRunning)
		{
			ChangeGameState(GameState.MatchEnded);
		}
	}
}

The game starts with a Prematch state by default. Thanks to the previously written PlayerScoresManager script, when the winner is selected, the game status changes to MatchEnded. However, the question of when to change the state of the game to GameplayMatchRunning remains unresolved.

Game initializer

You’d like the game to count down a certain amount of time before the start of the match so that all players have a chance to prepare for it. For this purpose, you’d have to create another class: GameInitializer. Its only task will be to count down to zero immediately after starting the game and to trigger appropriate feedback.

public class GameInitializer : ElympicsMonoBehaviour, IUpdatable
{
	[SerializeField] private float timeToStartMatch = 5.0f;

	public ElympicsFloat CurrentTimeToStartMatch { get; } = new ElympicsFloat(0.0f);

	private ElympicsBool gameInitializationEnabled = new ElympicsBool(false);

	private Action OnMatchInitializedAssignedCallback = null;

	public void InitializeMatch(Action OnMatchInitializedCallback)
	{
		OnMatchInitializedAssignedCallback = OnMatchInitializedCallback;

		CurrentTimeToStartMatch.Value = timeToStartMatch;
		gameInitializationEnabled.Value = true;
	}

	public void ElympicsUpdate()
	{
		if (gameInitializationEnabled)
		{
			CurrentTimeToStartMatch.Value -= Elympics.TickDuration;

			if (CurrentTimeToStartMatch <= 0.0f)
			{
				OnMatchInitializedAssignedCallback?.Invoke();

				gameInitializationEnabled.Value = false;
			}
		}
	}
}

In the editor, you specify how many seconds the countdown should last. You also provide an appropriate ElympicsFloat that stores the current countdown time. You’ll be able to subscribe to the appropriate methods responsible for displaying the time on the start screen to it. This class also has an appropriate ElympicsBool flag thanks to which it stops modifying the timer once the requested limit is reached. You also store the appropriate callback passed with the InitializeMatch method to call it when the timer reaches a certain value.

The InitializeMatch method is used to start the countdown. To do this, the gameInitializationEnabled flag is set to true and the passed callback is stored.

In the ElympicsUpdate method, if the countdown flag is true, you modify the variable responsible for storing the current timer with the Elympics.TickDuration value. If the timer counts down the specified time (its value will be less than or equal to 0), the stored callback is called and the value of the flag is changed to false again.

Updating the game state controller

With the GameInitializer prepared this way, you can return to your GameStateController and complete it:

public class GameStateController : ElympicsMonoBehaviour, IInitializable
{
	[Header("References:")]
	[SerializeField] private GameInitializer gameInitializer = null;
	[SerializeField] private PlayerScoresManager playerScoresManager = null;

	public ElympicsInt CurrentGameState { get; } = new ElympicsInt((int)GameState.Prematch);

	public void Initialize()
	{
		gameInitializer.InitializeMatch(() => ChangeGameState(GameState.GameplayMatchRunning));
	}

	private void ChangeGameState(GameState newGameState)
	{
		CurrentGameState.Value = (int)newGameState;
	}

	public void ElympicsUpdate()
	{
		if (playerScoresManager.WinnerPlayerId >= 0 && (GameState)CurrentGameState.Value == GameState.GameplayMatchRunning)
		{
			ChangeGameState(GameState.MatchEnded);
		}
	}
}

You add a reference to GameInitializer.cs and, in the Elympics Initialize method, call the method InitializeMatch() from the GameInitializer class, where you provide a function that modifies the current state of the game to GameplayMatchRunning as a callback.

We can now place the prepared scripts in the scene:

First-Person Shooter

3… 2… 1…

From now on, the match will start in the Prematch state. Then, after 5 seconds, it will go to the proper GameplayMatchRunning state. When one of the players reaches the designated number of points, the state of the game will be changed to MatchEnded.

First-Person Shooter

5 seconds to start, as promised!

In the next part we’ll create start and summary screen at the end of the game. 📢

12 - First-Person Shooter: Start screen, Summary screen

Tutorial: First-Person Shooter in Unity - Start screen, Summary screen

This is Elympics First-Person Shooter tutorial: part 12. In this part we’ll create start and summary screen at the end of the game. See: Part 11.

With the game divided into three stages, you can begin implementing the start screen. Thanks to it, players will be able to prepare for the match.

Start screen

Start by creating a simple UI object that will be your start screen in the scene: First-Person Shooter

In the above example, the start screen consists only of a darkened background and text fields that will display the time remaining until the start of the game.

The created object will also need a script that will control it: GameInitializationScreen.cs:

public class GameInitializationScreen : MonoBehaviour
{
	[SerializeField] private TextMeshProUGUI countdownToStartMatchText = null;
	[SerializeField] private CanvasGroup screenCanvasGroup = null;

	[SerializeField] private GameStateController gameStateController = null;
	[SerializeField] private GameInitializer gameInitializer = null;

	private void Awake()
	{
		gameInitializer.CurrentTimeToStartMatch.ValueChanged += UpdateTimeToStartMatchDisplay;

		ProcessScreenViewAtStartOfTheGame();
	}

	private void ProcessScreenViewAtStartOfTheGame()
	{
		SetScreenDisplayBasedOnCurrentGameState(-1, gameStateController.CurrentGameState);
		gameStateController.CurrentGameState.ValueChanged += SetScreenDisplayBasedOnCurrentGameState;
	}

	private void UpdateTimeToStartMatchDisplay(float lastValue, float newValue)
	{
		countdownToStartMatchText.text = Mathf.Ceil(newValue).ToString();
	}

	private void SetScreenDisplayBasedOnCurrentGameState(int lastGameState, int newGameState)
	{
		screenCanvasGroup.alpha = (GameState)newGameState == GameState.Prematch ? 1.0f : 0.0f;
	}
}

This script works very similarly to the DeathScreen.cs script created before. You need two references: one for the text field in which you’ll be displaying the current state of the timer and another one for CanvasGroup: a component that will control the display of the entire object.

You’ll obtain all the necessary information from two other scripts:

  • GameStateController: receiving information about the current state of the game will allow you to decide whether the entire GameInitializationScreen object should be visible or not;
  • GameInitializer: from this component, you’ll obtain information about the current value of the timer counting down to the start of the game.

The entire GameInitializtionScreen.cs script will be executed locally, so it doesn’t need any Elympics components. All the information received from other scripts uses ElympicsVars, so you can be sure that all the actions performed on their basis will be consistent with the actual state of the game.

The Awake() and ProcessScreenViewAtStartOfTheGame() methods are used to initialize the entire script at the start of the game: you subscribe to the appropriate events, from which you’ll obtain information on the basis of which the entire object will be operated.

The UpdateTimeToStartMatchDisplay() method, similarly to the DeathScreen.cs script, receives the current value of the timer of the GameInitializer class. The obtained value is modified using the Ceil method and then written to the appropriate text field: countdownToStartMatchText.

In the SetScreenDisplayBasedOnCurrentGameState method, you modify the alpha value of the CanvasGroup component depending on the current state of the game. The object should only be visible when the game state is Prematch.

The created script is added to the game scene and filled with appropriate references: First-Person Shooter

From now on, the game will count down the defined amount of time before the start of the match. Each player will see the GameInitializationScreen that will show the time remaining until the start of the game. First-Person Shooter

End screen

The next step is to create an end-of-game screen. Its logic will be very similar to the previous GameInitializationScreen script. So, start by creating a new class.

public class GameEndedScreen : MonoBehaviour
{
	[SerializeField] private TextMeshProUGUI gameWinnerText = null;
	[SerializeField] private CanvasGroup screenCanvasGroup = null;

	[SerializeField] private GameStateController gameStateController = null;
	[SerializeField] private PlayerScoresManager playerScoresManager = null;
	[SerializeField] private PlayersProvider playersProvider = null;

	private void Awake()
	{
		playerScoresManager.WinnerPlayerId.ValueChanged += SetWinnerInfo;

		gameStateController.CurrentGameState.ValueChanged += SetScreenDisplayBasedOnCurrentGameState;
	}

	private void SetWinnerInfo(int lastValue, int newValue)
	{
		var winnerData = playersProvider.GetPlayerById(newValue);

		gameWinnerText.text = $"Player {winnerData.transform.gameObject.name} won the game!";
	}

	private void SetScreenDisplayBasedOnCurrentGameState(int lastGameState, int newGameState)
	{
		screenCanvasGroup.alpha = (GameState)newGameState == GameState.MatchEnded ? 1.0f : 0.0f;
	}
}

This script, just like the previous one, makes the entire object visible if the game state is MatchEnded. Also, when the WinnerId variable in the PlayerScoresManager script is changed, the information about the winner displayed to your players will be updated.

Like in the case of the previous screen, at the start of the game, you create a new object that is your final screen. Then, you add the created script to it and complete it with the missing references: First-Person Shooter

Game ended!

From now on, at the end of the game, all players will see an appropriate screen informing them about the end of the game and the winner:

First-Person Shooter

In the next part we’ll explain how to synchronize animations. 🤸‍♀️🤾

13 - First-Person Shooter: Animation synchronization

Tutorial: First-Person Shooter in Unity - Animation synchronization

This is Elympics First-Person Shooter tutorial: part 13. In this part we’ll explain how to synchronize animations. See: Part 12.

At this moment, each player is represented by a capsule (Capsule Collider and Capsule Mesh), but they should be fully animated, humanoid characters. To achieve this, you’ll need to add appropriate animations, both for the full model of the character visible to other players and the player’s hands (a separate model visible only to the player controlling the specific character).

Synchronizing animations with Elympics

In this tutorial, we want to focus only on animation synchronization, so we’ll use a ready-made solution that can be found in the project shared with this guide.

The player character visible to opponents (humanoid model) is placed directly under the empty game object “CharacterModel” container: First-Person Shooter

And it consists of the following components:

First-Person Shooter

The most important component used for full animation synchronization is the Elympics Animator Synchronizer component that also requires the ElympicsBehaviour component to work properly:

First-Person Shooter

This component allows you to select variables and layers in the inspector that will be synchronized. For this reason, to handle the animator and its synchronization, you’ll only need a script that will set the appropriate variable in the animator locally, and Elympics will ensure that the animator is properly synchronized in other clients.

Let’s use the PlayerThirdPersonAnimatorMovementController script as an example. It’ll be responsible for setting the animator values related to the player’s movement locally:

[RequireComponent(typeof(Animator))]
public class PlayerThirdPersonAnimatorMovementController : MonoBehaviour
{
	[SerializeField] private MovementController playerMovementController;
	[SerializeField] private DeathController playerDeathController;

	private readonly int movementForwardParameterHash = Animator.StringToHash("MovementForward");
	private readonly int movementRightParameterHash = Animator.StringToHash("MovementRight");
	private readonly int jumpingTriggerParameterHash = Animator.StringToHash("JumpTrigger");
	private readonly int deathTriggerParameterHash = Animator.StringToHash("DeathTrigger");
	private readonly int resetTriggerParameterHash = Animator.StringToHash("ResetTrigger");
	private readonly int isGroundedParameterHash = Animator.StringToHash("IsGrounded");

	private Animator thirdPersonAnimator = null;

	private void Awake()
	{
		thirdPersonAnimator = GetComponent<Animator>();
		playerMovementController.MovementValuesChanged += ProcessMovementValues;
		playerMovementController.PlayerJumped += ProcessJumping;
		playerMovementController.IsGroundedStateUpdate += ProcessIsGroundedStateUpdate;
		playerDeathController.IsDead.ValueChanged += ProcessDeathState;
	}

	private void ProcessDeathState(bool lastValue, bool newValue)
	{
		if (newValue)
		{
			thirdPersonAnimator.SetTrigger(deathTriggerParameterHash);
		}
		else
		{
			thirdPersonAnimator.SetTrigger(resetTriggerParameterHash);
		}
	}

	private void ProcessIsGroundedStateUpdate(bool isGrounded)
	{
		thirdPersonAnimator.SetBool(isGroundedParameterHash, isGrounded);
	}

	private void ProcessJumping()
	{
		thirdPersonAnimator.SetTrigger(jumpingTriggerParameterHash);
	}

	private void ProcessMovementValues(Vector3 movementDirection)
	{
		var localMovementDirection = playerMovementController.transform.InverseTransformDirection(movementDirection) * 2.0f;

		thirdPersonAnimator.SetFloat(movementForwardParameterHash, localMovementDirection.z);
		thirdPersonAnimator.SetFloat(movementRightParameterHash, localMovementDirection.x);
	}
}

This script is for local animator support, so it doesn’t require any inheritance from ElympicsMonoBehaviour. To operate fully functionally, it needs references to the MovementController and DeathController components that provide information about the player’s current status (alive/dead) and the values of actions related to the movement they’re currently performing.

Based on the two components above, animator variables are updated accordingly:

  • Two float type variables responsible for storing information about the current direction and speed with which the player is moving updated based on the MovementValuesChanged event of the MovementController component;
  • One trigger whose method is subscribed to the PlayerJumped event and is responsible for performing the jump animation when the player jumps;
  • One bool variable that updates its state frame by frame depending on whether it’s on the ground or in the air;
  • Two trigger variables executed in the ProcessDeathState method, called when the player’s state has changed (alive-dead). These triggers are responsible for triggering the death animation or resetting the player’s animation to the idle (respawn) state.

All of these variables are used to handle the part of the animator shown below:

First-Person Shooter

All of these variables are set locally (many of them are based on the updated state, e.g. in the ElympicsUpdate method, so changes will only take place for the player controlled by the client and on the server). However, thanks to the use of the ElympicsAnimatorSynchronizer component, they will be visible to all the players in the game.

The animator of hands visible to the player you’re currently controlling is constructed very similarly to the previously discussed PlayerThirdPersonAnimatorMovementController script. However, it doesn’t have the ElympicsAnimatorSynchronizer component: its animations are visible only to the local player anyway.

Finally, all the animations are synchronized properly:

First-Person Shooter

That’s all!

Congratulations! 🎉🎉

You’ve created a fully functional, server-authoritative, multiplayer FPS game!

Feel free to experiment and expand this project to create an FPS game you’ve always dreamt of!