Inputs

Correct designing and implementing players inputs in Elympics with examples.

Basics

IInputWriter & IInputReader

Inputs in Elympics are binary data written in a defined order. This requires developers to ensure compatibility between serialization and deserialization.

Serialization

    inputWriter.Write(vMove); // float
    inputWriter.Write(hMove); // float
    inputWriter.Write(fire); // bool
    if (fire)
      inputWrite.Write(firePower); // float
  
Deserialization

    inputReader.Read(out float vMove);
    inputReader.Read(out float hMove);
    inputReader.Read(out bool fire);
    float firePower = 0.0f;
    if (fire)
      inputReader.Read(out firePower);
    

Collecting inputs

To collect inputs Elympics searches for IInputHandler implementations on GameObjects with ElympicsBehaviour component. Then the provided input collecting methods are called during FixedUpdate (see Game Loop)

public class PlayerInputController : ElympicsMonoBehaviour, IInputHandler
{
	public void OnInputForClient(IInputWriter inputWriter)
	{
		var vMove = Input.GetAxis("Vertical");
		var hMove = Input.GetAxis("Horizontal");
		var fire = Input.GetKey(KeyCode.Space);
		SerializeInput(inputWriter, vMove, hMove, fire);
	}

	public void OnInputForBot(IInputWriter inputWriter)
	{
		var vMove = Random.Range(-1.0f, 1.0f);
		var hMove = Random.Range(-1.0f, 1.0f);
		var fire = true;
		SerializeInput(inputWriter, vMove, hMove, fire);
	}

	private static void SerializeInput(IInputWriter inputWriter, float vMove, float hMove, bool fire)
	{
		inputWriter.Write(vMove);
		inputWriter.Write(hMove);
		inputWriter.Write(fire);
	}

	{...}
} 

From Client - OnInputForClient

Collecting inputs for clients is as simple as getting the current state of mouse, keyboard, or any other controller.

From Bot - OnInputForBot

In the example provided above, bot is programmed to perform random moves. However there are endless possibilities to implement bot behaviour. In most situations bots are run along with the server, so they are aware of full state of the game. There is also option to run bots in a similar way to player clients, as independent processes.

Empty OnInput implementation

In case you don’t want your bot (or client) to produce any input yet, you can leave OnInput* empty.

public void OnInputForClient(IInputWriter inputWriter) {...}

public void OnInputForBot(IInputWriter inputWriter)
{
	// TODO
}

In this case input won’t be serialized and sent, and ElympicsBehaviour.TryGetInput will return false like when the input is absent.

Reading inputs

ElympicsMonoBehaviour base class provides a way to access previously generated inputs - they are available in ElympicsBehaviour.TryGetInput. Of course if your class doesn’t inherit from ElympicsMonoBehaviour, you can get the necessary component by yourself using GetComponent method.

public class PlayerInputController : ElympicsMonoBehaviour, IInputHandler, IUpdatable
{
	public void OnInputForClient(IInputWriter inputWriter) {...}
	public void OnInputForBot(IInputWriter inputWriter) {...}

	private float vMove = 0.0f;
	private float hMove = 0.0f;
	private bool fire = false;
	private int inputAbsenceFallbackTicks = 4;

	public void ElympicsUpdate()
	{
		// Parameter 0 means that we try to read input from player 0
		if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out var inputReader, inputAbsenceFallbackTicks))
		{
			inputReader.Read(out vMove);
			inputReader.Read(out hMove);
			inputReader.Read(out fire);
		}

		Move(vMove, hMove);
		Fire(fire);
	}

	private void Move(float vMove, float hMove)
	{
		// Movement implementation
	}

	private void Fire(bool fire)
	{
		// Firing implementation
	}
}

Handling multiple players

Elympics doesn’t have object ownership feature built-in (yet!). However it’s really easy to implement! It is necessary to prevent players from e.g. controlling other players characters.

Let’s get back to our PlayerInputController, where OnInputForClient is implemented. Considering multiple players, we allowed all of them to create inputs for every PlayerInputController. Considering we have 2 or more of those controllers instantiated (we need one for each connected player), OnInputForClient will be called for each of those instances. To prevent this, we have to check which instance is currently allowed to call this method (are we handling the instance responsible for current player?).

To start, we need to set predictability in every character’s ElympicsBehaviour to the chosen player.

PredictableFor Player

Then we can use it (through ElympicsMonoBehaviour.PredictableFor property) in the following way:

public void OnInputForClient(IInputWriter inputWriter)
{
	// Check for ownership
	if (Elympics.Player != PredictableFor)
		return;

	var vMove = Input.GetAxis("Vertical");
	var hMove = Input.GetAxis("Horizontal");
	var fire = Input.GetKey(KeyCode.Space);
	SerializeInput(inputWriter, vMove, hMove, fire);
}

Secondly, we also need to alter our ElympicsUpdate implementation. In the previous example the input that we used was collected from player 0. Now we have to choose which player (or players) can influence the current PlayerInputController.

private float vMove = 0.0f;
private float hMove = 0.0f;
private bool fire = false;

public void ElympicsUpdate()
{
	// PredictableFor is used here to filter only the owner's input
	if (ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
	{
		inputReader.Read(out vMove);
		inputReader.Read(out hMove);
		inputReader.Read(out fire);
	}

	Move(vMove, hMove);
	Fire(fire);
}

Custom field

You can also use custom serialized int instead of PredictableFor, e.g.

[SerializeField] private int player;
{...}
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(player), out var inputReader))

but in most cases PredictableFor will be the same as player.

Important coding principles

These are the most crucial rules for inputs programming and should always be followed!

Collect-apply split

It is crucial to separate the code of collecting inputs and applying them (which is not the way you usually work on a single-player game). Collecting inputs and serializing them to simple data should be kept inside OnInput* methods. Applying inputs from simple data representation to the game world should be separated and called inside ElympicsUpdate (this is your game logic). This is required by the architecture of server authority paradigm i.e. input collected by clients have to be synchronized and cause exactly the same effect both on the client and on the server instance. If that’s not the case, the state on the client will be broken.

Example for moving a character:

  • OnInputForClient(): Save joystick axes positions as float values to the input writer
  • ElympicsUpdate(): Read joystick axes positions from the input reader and move the character appropriately.

Input absence vs empty input

Remember to properly handle the absence of input. Below you can see a simple example explaining how the absence of input can be exploited in your game. Let’s modify the previous example a bit:

public void ElympicsUpdate()
{
	if (!ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out var inputReader))
		return;

	inputReader.Read(out var vMove);
	inputReader.Read(out var hMove);
	inputReader.Read(out var fire);

	Move(vMove, hMove);
	Fire(fire);
}

private void Move(float vMove, float hMove)
{
	rigidbody.velocity = new Vector2(hMove, vMove);
}

Can you see the danger of not handling the absence of input?
If there is no decelerating force or friction, the rigidbody will continue to move at previously set velocity instead of stopping (like it would with zero-input).

The absence of input could be handled in many other ways. For example you could extrapolate missing input and use the one cached from a previous tick as we did in the first example in this article (recommended way). Or you could interpret it as zero-input. It really depends on your specific use case.

Examples

FPS-like camera controller

To create FPS-like camera controller you have to read mouse movements properly and process its accumulated increments. We won’t do it inside OnInputForClient because this method is called during FixedUpdate, and with framerate greater than tickrate some increments would be lost.

To collect these, we have to use Unity Update instead and cache mouse movements in a variable. Then, during OnInputForClient we can read current mouse position and serialize it as network input.

public class PlayerInputController : ElympicsMonoBehaviour, IInputHandler, IUpdatable
{
	[SerializeField] private float mouseSensivity = 1.5f;

	private Vector2 mouseAxis = Vector2.zero;

	private void Update()
	{
		var mouseX = Input.GetAxis("Mouse X");
		var mouseY = Input.GetAxis("Mouse Y");
		mouseAxis += new Vector2(mouseY, mouseX) * mouseSensivity;
	}

	public void OnInputForClient(IInputWriter inputWriter)
	{
		if (Elympics.Player != PredictableFor)
			return;

		inputWriter.Write(mouseAxis.x);
		inputWriter.Write(mouseAxis.y);
	}

	public void ElympicsUpdate()
	{
		if (ElympicsBehaviour.TryGetInput(PredictableFor, out var inputReader))
		{
			inputReader.Read(out float mouseX);
			inputReader.Read(out float mouseY);

			// Set only if received input
			transform.localRotation = Quaternion.Euler(new Vector3(mouseX, mouseY, 0));
		}
	}

	{...}
}

Multiple players controlling the same object

TicTacToe is the perfect example of a game in which both players can change state of a shared game board, so ownership is hard to define.
In such cases, the proper way to configure such shared synchronized object to work seamlessly is to set its predictability to None (ElympicsPlayer.World in code).
Then we can send the last chosen field (desired move) as player’s input, and validate inside ElympicsPlayer whether that player is allowed to make such move.

public void GetInputForClient(IInputWriter inputWriter)
{
	var field = playerInputProvider.GetLastClickedField();
	inputWriter.Write(field);
}

public void ElympicsUpdate()
{
	for(var i = 0; i < 2; i++) {
		var player = ElympicsPlayer.FromIndex(i);
		if (ElympicsBehaviour.TryGetInput(player, out var inputReader))
		{
			inputReader.Read(out float field);

			if (!gameState.ValidateMove(player, field))
				continue;

			gameState.SetFieldForPlayer(player, field);
			gameState.ChangeTurn();
		}
	}
}