Inputs
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
- Deserialization
inputWriter.Write(vMove); // float
inputWriter.Write(hMove); // float
inputWriter.Write(fire); // bool
if (fire)
inputWrite.Write(firePower); // float
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);
In the examples we will consider having one player only. Handling multiple players isn't a lot different, but as a simplification we will leave it for later.
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)
There can be at most one component with IInputHandler
per ElympicsBehaviour
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.
Do not use Input.GetKeyDown()
or Input.GetKeyUp()
as they are not available in FixedUpdate
inside which OnInputForClient
is called.
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.
Inputs should only be deserialized (by calling TryGetInput
) and applied inside ElympicsUpdate
, as they are tick-synchronized and guaranteed to be present only in that context.
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
}
}
inputReader
is reused by Elympics and you have to read whole input before trying to get the next one, otherwise exception will be thrown.
inputAbsenceFallbackTicks
is an optional parameter (default value is 4) considered by server only if it doesn't receive input from client for a given tick (read more below to find out why this could happen). It instructs the server on how old input should be considered relevant in such cases.
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?).
Ownership is more or less related to prediction. This means that if player has ownership of an object, they could predict its movement using the same inputs sent to the server applied locally in ElympicsUpdate
. (learn more about predition, and its role in the game loop).
To start, we need to set predictability in every character's ElympicsBehaviour
to the chosen 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.
Inputs are collected on the client only, and synchronized with the server by Elympics. They are later applied on both the client and the server.
Example for moving a character:
OnInputForClient()
: Save joystick axes positions as float values to the input writerElympicsUpdate()
: 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.
There can be many reasons why input for a particular tick is absent. For example player's lag could have increased, thus not being able to deliver inputs at the appropriate time. You should always be prepared for the case of absent input, even in you generate it for every player at every tick.
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();
}
}
}