Skip to main content

Asynchronous multiplayer: Gameplay logic

Creating an input system

Since we’re working with a server authoritative architecture, all input should be sent, received and then processed. We will be sending input from our PlayerManager script.

To do that, we make it implement the IInputHandler interface from the Elympics library. We need to create a private variable from which we will take the value to send it to the server. Let’s make it a private int and create a public method for setting its value which we’ll call when pressing the buttons on the UI. Implementing the IInputHandler interface should create two methods: OnInputForBot and OnInputForClient. Those are the methods that send input to the server instance each tick, for the Bots and Players respectively. Let’s leave the body of the OnInputForBot method empty, since we won’t be using bots in our project. Inside of the OnInputForClient method we use the Write method from the provided inputSerializer, and pass into it our currentInput integer. After sending the input value we reset it to its neutral value of 0 to not send our singular input twice. If you were creating a game in which player movement is continuous you wouldn’t need to reset it. Elympics input system is explained in detail here.

using UnityEngine;
using Elympics;

public class PlayerManager : MonoBehaviour, IInputHandler
{
private int currentInput;
public void SetCurrentInput(int newDirection) => currentInput = newDirection;

public void OnInputForBot(IInputWriter inputSerializer) { }

public void OnInputForClient(IInputWriter inputSerializer)
{
inputSerializer.Write(currentInput);
currentInput = 0;
}
}

The input will be used to move the player between right and left so let’s add a synchronized variable to store information about their current position. These are the Elympics Variables. You can read about them in detail here. We will create a private boolean using the ElympicsBool type. Elympics variables need to be initialized before we can access their value. Let’s also create a public getter and setter for the variable. Once finished, the PlayerManager script should look something like this:

using UnityEngine;
using Elympics;

public class PlayerManager : MonoBehaviour, IInputHandler
{
private readonly ElympicsBool isOnTheLeft = new ElympicsBool(false);
public bool IsOnTheLeft => isOnTheLeft.Value;
public void SetIsOnTheLeft(bool onTheLeft) => isOnTheLeft.Value = onTheLeft;

private int currentInput;
public void SetCurrentInput(int newDirection) => currentInput = newDirection;

public void OnInputForBot(IInputWriter inputSerializer) { }

public void OnInputForClient(IInputWriter inputSerializer)
{
inputSerializer.Write(currentInput);
currentInput = 0;
}
}

Remember to call SetCurrentInput when clicking the buttons set up on our scene.

CTZ

CTZ

With input being sent properly from the PlayerManager script we now have to receive it from the GameManager script. The reason why we can read input sent from another script is because both scripts are attached to the same ElympicsBehaviour component on our Game manager object. Receiving inputs happens in the main game loop used by Elympics for synchronized logic. It works very similar to FixedUpdate in unity, however it uses Elympics Tick duration instead of fixedDeltaTime for the calculation of the logic step. To access it from a script, your class must inherit from the ElympicsMonobehaviour class and implement the IUpdatable interface.

using UnityEngine;
using Elympics;

public class GameManager : ElympicsMonoBehaviour, IUpdatable, IInitializable, IClientHandlerGuid
{
[SerializeField] private PlayerManager playerManager;

public void ElympicsUpdate()
{

}
}

To read the input we use the ElympicsBehaviour.TryGetInput() method. In it we specify which player we want to read the input from. In case of a single player game it will always be the player with the index of 0. We collect the input using the Read method of an IInputReader object. In case of sending multiple variables as inputs, it’s important to always read them in the exact same order as the one they were sent in.

public void ElympicsUpdate()
{
int readInput = 0;
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out IInputReader inputReader))
{
inputReader.Read(out readInput);
}
}

Due to the unstable nature of the internet, it’s important to keep game logic independent of receiving input successfully. That is why while reading of the input is enclosed in an if statement, processing it will happen outside of it.

Let’s check if the input we’ve received is not a neutral input and if so, let’s set players position to either left or right.

public void ElympicsUpdate()
{
int readInput = 0;
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out IInputReader inputReader))
{
inputReader.Read(out readInput);
}
if (readInput != 0)
{
if (readInput == -1) playerManager.SetIsOnTheLeft(true);
else if (readInput == 1) playerManager.SetIsOnTheLeft(false);
}
}

To display player position let’s create a method in our DisplayManager script.

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using TMPro;

public class DisplayManager : MonoBehaviour
{
[SerializeField] private List<Image> mapTiles;
[SerializeField] private RectTransform playerTransform;
[SerializeField] private Slider timerSlider;
[SerializeField] private TextMeshProUGUI scoreDisplay;
[SerializeField] private TextMeshProUGUI finalScoreDisplay;
[SerializeField] private GameObject gameOverScreen;

public void DislplayPlayer(bool playerOnTheLeft)
{
playerTransform.anchoredPosition = new Vector2(Mathf.Abs(playerTransform.anchoredPosition.x) * (playerOnTheLeft ? -1 : 1), playerTransform.anchoredPosition.y);
}
}

And call it from GameManager after moving the player.

public void ElympicsUpdate()
{
int readInput = 0;
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out IInputReader inputReader))
{
inputReader.Read(out readInput);
}
if (readInput != 0)
{
if (readInput == -1) playerManager.SetIsOnTheLeft(true);
else if (readInput == 1) playerManager.SetIsOnTheLeft(false);
displayManager.DisplayPlayer(playerManager.IsOnTheLeft);
}
}

Creating tile randomisation

Now let’s create logic for our obstacles. The information about which tiles contain obstacles will be stored in an synchronized array of booleans, which will hold the ‘true’ value in each slot with an obstacle. ElympicsArrays are different from regular elympics variables in that their values need to be initialized at the beginning of of the game and not before it. To do that we will use the Initialize method from the IInitializable interface.

using System.Collections.Generic;
using UnityEngine;
using Elympics;

public class MapManager : MonoBehaviour, IInitializable
{
private ElympicsArray<ElympicsBool> obstacleList = null;

[SerializeField] private int rowsCount = 5;

public void Initialize()
{
obstacleList = new ElympicsArray<ElympicsBool>(2 * rowsCount, () => new ElympicsBool(false));
}
}

To fill in the map as the game progresses we need 2 methods, one to move the rows down and one to generate the new row. To have a randomized map in a server authoritative game we must use a predictable random. It works by generating new System.Random from a combination of a set seed and a predictable modifier, each time there’s a need for a set of random values. You can read in detail about predictable randomization here.

Based on our new random, let’s choose one of the 3 options, where there’s an obstacle on the left, on the right or no obstacle at all, and use them to fill the final row.

using System.Collections.Generic;
using UnityEngine;
using Elympics;

public class MapManager : MonoBehaviour, IInitializable
{
private ElympicsArray<ElympicsBool> obstacleList = null;

[SerializeField] private int rowsCount = 5;
[SerializeField] private int seed = 1;

public void Initialize()
{
obstacleList = new ElympicsArray<ElympicsBool>(2 * rowsCount, () => new ElympicsBool(false));
}

private void MoveRows()
{
for (int i = 0; i < rowsCount - 1; i++)
{
obstacleList.Values[2 * i].Value = obstacleList.Values[2 * i + 2].Value;
obstacleList.Values[2 * i + 1].Value = obstacleList.Values[2 * i + 3].Value;
}
}

private void GenerateNewRow(int seedModifier)
{
var rng = new System.Random(seed + seedModifier);
int option = rng.Next(0, 3);
bool newLeft = option == 1;
bool newRight = option == 2;

obstacleList.Values[rowsCount * 2 - 2].Value = newLeft;
obstacleList.Values[rowsCount * 2 - 1].Value = newRight;
}

public void UpdateRows(int seedModifier)
{
MoveRows();
GenerateNewRow(seedModifier);
}
}

From our GameManager, let’s call the UpdateRows() each time the player moves. In our case to make the game more random we’ll pass current Tick number in as the predictable modifier for our method.

public void ElympicsUpdate()
{
int readInput = 0;
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out IInputReader inputReader))
{
inputReader.Read(out readInput);
}
if (readInput != 0)
{
if (readInput == -1) playerManager.SetIsOnTheLeft(true);
else if (readInput == 1) playerManager.SetIsOnTheLeft(false);
displayManager.DisplayPlayer(playerManager.IsOnTheLeft);
mapManager.UpdateRows((int)Elympics.Tick);
}
}

Finally let’s display the changes. In mapManager we create a method for getting our map in the form of a list of booleans.

public List<bool> GetObstacleList()
{
List<bool> result = new List<bool>();
foreach (ElympicsBool obstacle in obstacleList.Values) result.Add(obstacle.Value);
return result;
}

In our displayManager we create a method displaying the obstacles, by changing the color of a tile to green if it’s safe or to red if it contains an obstacle.

public void DisplayObstacles(List<bool> obstacleList)
{
for (int i = 0; i < mapTiles.Count; i++)
{
mapTiles[i].color = obstacleList[i] ? Color.red : Color.green;
}
}

Finally let’s call the display method each time we update the obstacle rows.

public void ElympicsUpdate()
{
int readInput = 0;
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out IInputReader inputReader))
{
inputReader.Read(out readInput);
}
if (readInput != 0)
{
if (readInput == -1) playerManager.SetIsOnTheLeft(true);
else if (readInput == 1) playerManager.SetIsOnTheLeft(false);
displayManager.DisplayPlayer(playerManager.IsOnTheLeft);
mapManager.UpdateRows((int)Elympics.Tick);
var obstacleList = mapManager.GetObstacleList();
displayManager.DisplayObstacles(obstacleList);
}
}

Checking for collision

With our map logic working, we must now check if the player is colliding with any obstacles. In GameManager, after the rows have been updated, let’s check if there’s an obstacle on the first row, where the player is currently standing. If the player is safe, we will increase the score, which we will keep as an ElympicsInteger and if he isn’t we’ll end the game.

using UnityEngine;
using Elympics;
using System.Collections.Generic;

public class GameManager : ElympicsMonoBehaviour, IUpdatable
{
[SerializeField] private PlayerManager playerManager;
[SerializeField] private MapManager mapManager;
[SerializeField] private DisplayManager displayManager;

private ElympicsInt score = new ElympicsInt();


public void ElympicsUpdate()
{
int readInput = 0;
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out IInputReader inputReader))
{
inputReader.Read(out readInput);
}

if (readInput != 0)
{
if (readInput == -1) playerManager.SetIsOnTheLeft(true);
else if (readInput == 1) playerManager.SetIsOnTheLeft(false);
displayManager.DislplayPlayer(playerManager.IsOnTheLeft);

mapManager.UpdateRows((int)Elympics.Tick);
var obstacleList = mapManager.GetObstacleList();
displayManager.DisplayObstacles(obstacleList);

if ((playerManager.IsOnTheLeft && obstacleList[0]) || (!playerManager.IsOnTheLeft && obstacleList[1]))
{
EndGame();
}
else
{
score.Value += 1;
}
}
}

private void EndGame()
{
if (Elympics.IsServer) Elympics.EndGame();
}
}

In DisplayManager let’s create methods for displaying the score and the game over screen.

public void DisplayScore(int score)
{
scoreDisplay.text = score.ToString();
}
public void ShowGameOver(int finalScore)
{
finalScoreDisplay.text = "Score: " + finalScore.ToString();
gameOverScreen.SetActive(true);
}

After that let’s call those methods from GameManager.

public class GameManager : ElympicsMonoBehaviour, IUpdatable
{
[SerializeField] private PlayerManager playerManager;
[SerializeField] private MapManager mapManager;
[SerializeField] private DisplayManager displayManager;

private ElympicsInt score = new ElympicsInt();


public void ElympicsUpdate()
{
int readInput = 0;
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out IInputReader inputReader))
{
inputReader.Read(out readInput);
}

if (readInput != 0)
{
if (readInput == -1) playerManager.SetIsOnTheLeft(true);
else if (readInput == 1) playerManager.SetIsOnTheLeft(false);
displayManager.DislplayPlayer(playerManager.IsOnTheLeft);

mapManager.UpdateRows((int)Elympics.Tick);
var obstacleList = mapManager.GetObstacleList();
displayManager.DisplayObstacles(obstacleList);

if ((playerManager.IsOnTheLeft && obstacleList[0]) || (!playerManager.IsOnTheLeft && obstacleList[1]))
{
EndGame();
}
else
{
score.Value += 1;
displayManager.DisplayScore(score.Value);
}
}
}

private void EndGame()
{
if (Elympics.IsServer) Elympics.EndGame();
displayManager.ShowGameOver(score.Value);
}
}

Adding a timer

For a game to not last forever, let’s also create the final part of the games logic which is the timer. We’ll set its initial value at the beginning of the game in the Initialize method and decrease it each ElympicsUpdate by the Tick duration. Once the value reaches 0 we’ll end the game.

using UnityEngine;
using Elympics;
using System.Collections.Generic;

public class GameManager : ElympicsMonoBehaviour, IUpdatable, IInitializable
{
[SerializeField] private PlayerManager playerManager;
[SerializeField] private MapManager mapManager;
[SerializeField] private DisplayManager displayManager;
[SerializeField] private float initialTimerValue;

private ElympicsInt score = new ElympicsInt();
private ElympicsFloat timer = new ElympicsFloat();

public void Initialize()
{
timer.Value = initialTimerValue;
}

public void ElympicsUpdate()
{
int readInput = 0;
if (ElympicsBehaviour.TryGetInput(ElympicsPlayer.FromIndex(0), out IInputReader inputReader))
{
inputReader.Read(out readInput);
}

if (readInput != 0)
{
if (readInput == -1) playerManager.SetIsOnTheLeft(true);
else if (readInput == 1) playerManager.SetIsOnTheLeft(false);
displayManager.DislplayPlayer(playerManager.IsOnTheLeft);

mapManager.UpdateRows((int)Elympics.Tick);
var obstacleList = mapManager.GetObstacleList();
displayManager.DisplayObstacles(obstacleList);

if ((playerManager.IsOnTheLeft && obstacleList[0]) || (!playerManager.IsOnTheLeft && obstacleList[1]))
{
EndGame();
}
else
{
score.Value += 1;
displayManager.DisplayScore(score.Value);
}
}

timer.Value -= Elympics.TickDuration;
displayManager.DisplayTimer(timer.Value / initialTimerValue);
if (timer.Value <= 0) EndGame();
}

private void EndGame()
{
if (Elympics.IsServer) Elympics.EndGame(new ResultMatchPlayerDatas(new List<ResultMatchPlayerData> { new ResultMatchPlayerData { MatchmakerData = new float[1] { score.Value } } }));
displayManager.ShowGameOver(score.Value);
}
}

In our DisplayManager we create a method to display remaining timer value on a slider.

public void DisplayTimer(float timerFill)
{
timerSlider.value = timerFill;
}

And call it in GameManager after decreasing the value of the timer.

timer.Value -= Elympics.TickDuration;
displayManager.DisplayTimer(timer.Value / initialTimerValue);
if (timer.Value <= 0) EndGame();

Finishing the gameplay

With our gameplay logic we need to make sure that every reference is properly set up on our scene.

CTZ

When finished make sure to test your game using the Half Remote mode, to make sure that there are no synchronization issues between the instances.