Skip to main content

First-Person Shooter: Player movement

info

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! 👀📽