How To Make A Replay System For Inputs In Unity

Posted by : on (Updated: )

Category : Unity

Introduction

Many times we find ourselves trying to test our game by doing the same thing in play mode again and again. For example, we may have a bug that we try to fix, but we cannot test it in isolation, instead we make a fix, hit play mode and then do the same movements of our character to see if the bug still exists. If the bug isn’t fixed, we exit play mode, try another fix and then do the same thing again.

This repetition is a productivity killer, especially if we have a bug that doesn’t always happen, but only happens 1 in 5 or 1 in 10 times. Below I will show a way that I have found, that allows us to record all of our inputs, so that after fixing a bug, we can have our game play automatically, by replaying our recorded inputs.

I will describe every step, so that the reader can adapt it to his specific use case.

The initial script

I will use as an example, the script from my How to use AddForce with Impulse in Unity to have constant speed for a certain distance post. This script, is not important for what its does, we only care about the inputs here. Here is the relevant part:

using UnityEngine;
using UnityEngine.InputSystem;

public class Ball : MonoBehaviour
{
    [SerializeField] private Rigidbody2D rb;
    [SerializeField] private SpriteRenderer sr;
    [SerializeField] private float range = 10f;
    [SerializeField] private float speed = 5f;

    private float _movementTimer;
    private void Update()
    {
        if (Mouse.current.scroll.ReadValue().y > 0f)
            range++;

        if (Mouse.current.scroll.ReadValue().y < 0f)
            range--;
        
        if(Mouse.current.leftButton.wasPressedThisFrame)
            Move(Camera.main.ScreenToWorldPoint(Mouse.current.position.ReadValue()));
        
        if (Keyboard.current.spaceKey.wasPressedThisFrame)
            sr.color = sr.color == Color.white ? Color.blue : Color.white;
        
        if(_movementTimer > 0f)
            _movementTimer -= Time.deltaTime;
    }

    private void FixedUpdate() {...}

    private void OnDrawGizmos() {...}

    private void Move(Vector2 pos) {...}
}

The input here is not how the input would be implemented in a real game, it is just a series of if statements, but this makes for a good refactoring example for displaying how to implement an input replay system.

What The Input Replay System Is About

Before we continue, I would like to point out, that the system I’m going to write about, is not a system that shows a replay of what has happened before. It is a system that replays the inputs of the user. If things are not deterministic in our game, then with the same inputs, different things will happen.

This is a good thing. We don’t want the same things to happen again and again, we want to test, given the same inputs, how our game reacts.

Because we can have inputs in our game from many sources, what we actually care about, are not the inputs themselves, but their result. In the script above for example, we don’t care about recording that the space key was pressed, but we care that the color of our ball changed at a specific point in time when we pressed the space key.

For this reason, every result of our inputs, should be a method that can be called and recorded. Let’s try the first, the method calling part, by refactoring the ball script.

Refactoring The Ball Script

using UnityEngine;
using UnityEngine.InputSystem;

public class BallRefactored : MonoBehaviour
{
    [SerializeField] private Rigidbody2D rb;
    [SerializeField] private SpriteRenderer sr;
    [SerializeField] private float range = 10f;
    [SerializeField] private float speed = 5f;

    private float _movementTimer;

    public float Range
    {
        get => range;
        set => range = value;
    }

    private void Update()
    {
        if(_movementTimer > 0f)
            _movementTimer -= Time.deltaTime;
    }

    public void ChangeColor() => sr.color = sr.color == Color.white ? Color.blue : Color.white;

    private void FixedUpdate() {...}

    private void OnDrawGizmos() {...}
    
    public void Move(Vector2 pos) {...}
}

Here we removed everything that has to do with inputs, as our script should know nothing about our input methods, refactored the statement that changes the color in its own method the ChangeColor method and added a Range property.

Our input method, will be in its own script. But before that, we should consider, how we can encapsulate the behavior of our ball, by separating the input source from the result.

Here we will use the Command Pattern. Let’s create a command for each of our four different inputs.

Creating The Commands

First we create a common interface ICommand. Besides the Execute method, it will have two more types.

The first type will be a CommandType property, that is an enum holding all our different inputs. We need this enum, because we want to serialize all the necessary info for saving the commands to a json file.

The second type will be a CommandParameters type. This is a type that we will create to hold values that can be serialized and are used as parameters in our commands. In our example, it will have only one field, a Vector2 field, which is the parameter that
hold the coordinates of our Move command.

So before the interface ICommand, here are the other two types:

[Serializable]
public enum CommandType
{
   IncreaseRange,
   DecreaseRange,
   ChangeColor,
   Move
}

and

[Serializable]
public struct CommandParameters
{
   public Vector2 Coordinates;
}

Now we can create our ICommand interface.

public interface ICommand
{
   CommandType CommandType { get; }
   CommandParameters CommandParameters { get; }
   public void Execute();
}

Now let’s create the concrete commands, that contain the behavior that should be executed each time there is a user input:

The increase Range:

public class IncreaseRangeCommand : ICommand
{
   public CommandType CommandType => CommandType.IncreaseRange;
   public CommandParameters CommandParameters { get; }
   
   private readonly BallRefactored _ball;
   
   public IncreaseRangeCommand(BallRefactored ball) => _ball = ball;
   
   public void Execute() => _ball.Range++;
}

The decrease range:

public class DecreaseRangeCommand : ICommand
{
   public CommandType CommandType => CommandType.DecreaseRange;
   public CommandParameters CommandParameters { get; }
   
   private readonly BallRefactored _ball;

   public DecreaseRangeCommand(BallRefactored ball) => _ball = ball;
   
   public void Execute() => _ball.Range--;
}

The change color:

public class ChangeColorCommand : ICommand
{
   public CommandType CommandType => CommandType.ChangeColor;
   public CommandParameters CommandParameters { get; }
   
   private readonly BallRefactored _ball;

   public ChangeColorCommand(BallRefactored ball) => _ball = ball;
   
   public void Execute() => _ball.ChangeColor();
}

and finally the Move command:

public class MoveCommand : ICommand
{
   public CommandType CommandType => CommandType.Move;
   public CommandParameters CommandParameters => _commandParameters;

   public Vector2 Pos
   {
      get => _pos;
      set
      {
         _pos = value;
         _commandParameters.Coordinates = Pos;
      }
   }

   private readonly BallRefactored _ball;
   private CommandParameters _commandParameters;
   private Vector2 _pos;

   public MoveCommand(BallRefactored ball) => _ball = ball;

   public void Execute() => _ball.Move(_commandParameters.Coordinates);
}

Here we see the reason for our CommandParameters type. We need to be able to get that parameter, before we serialize and save our json file containing the commands we want to record, but we also need to set it during our replay. For this reason, we don’t set it directly, but we use a Vector2 property Pos, that doesn’t only assign a value to our _pos field, but also assigns the value to a field in our CommandParameters.

In case we have different commands, that have different parameters, we can use the same fields in our CommandParameters type to represent something else. The Vector2 Coordinates field here stores our move position coordinates, but if we had another command that needed a Vector2 field, we could use it for that field.

There is also the ball field. In this example we only have one ball, so we can cache our commands as we will see, to avoid the garbage collector.

If we had many balls and there was no input to choose a ball, for example after every move the game would randomly choose a ball for us, then we would need a way to discern between all our balls. In that case, we should have an ID for each of our balls and use that in our commands to indicate which ball each command is for, by mapping the IDs to our balls. The reason for that, is we need something serializable to save, and our balls probably won’t be, but our IDs will.

The Invoker class

Now that we have created our commands, let’s create our Invoker class.

public class Invoker
{
   public ICommand Command { get; set; }
   public List<CommandLog> CommandsLog { get; }

   private readonly bool _isRecordingCommands;

   public Invoker(bool isRecordingCommands)
   {
      _isRecordingCommands = isRecordingCommands;
      CommandsLog = new List<CommandLog>();
   }

   public void Execute()
   {
      if (_isRecordingCommands)
      {
         CommandLog commandLog = new()
         {
            CommandType = Command.CommandType,
            CommandParameters = Command.CommandParameters,
            FrameExecuted = Time.frameCount
         };
         
         CommandsLog.Add(commandLog);
      }
      
      Command.Execute();
   }
}

Two important things are happening here:

1) We can set the relevant command and execute it as we usually do with any invoker implementation.

2) We have a boolean that indicates if we are in recording mode. If we are, then we add to a list a CommandLog type. The CommandLog type holds the following info: The command type, The command parameters and the frame the command was executed.

We need that List, so that we can serialize it and save it in a json file, so that later we can replay our commands.

Here is the CommandLog type:

[Serializable]
public struct CommandLog
{
   public int FrameExecuted;
   public CommandType CommandType;
   public CommandParameters CommandParameters;
}

The GamePlayCommandsIO class

Two things remain to do, the first is a mechanism to save and load our serialized variables, the second is the class that is responsible for the inputs themselves.

Let’s see the first:

public class GamePlayCommandsIO
{
   private readonly string _jsonPath;
   
   public GamePlayCommandsIO(string fileName)
   {
      if (fileName.Length == 0)
         throw new InvalidDataException("Not valid filename.");
      
      _jsonPath = "Assets/Replay Input System/Replay System/" + fileName + ".json";
   }
   
   public void Save(List<CommandLog> commandsLog)
   {
      var gameInputData = new GameInputData
      {
         CommandLog = commandsLog
      };

      var actionInfo = JsonUtility.ToJson(gameInputData); 
      File.WriteAllText(_jsonPath,actionInfo);
   }

   public List<CommandLog> Load()
   {
      var file = File.ReadAllText(_jsonPath);
      return JsonUtility.FromJson<GameInputData>(file).CommandLog;
      
   }
}

This is a simple class, that has two methods. One for saving and one for loading. To be able to save our serialized info that exists in our List<CommandLog> we wrap it in a GameInputData class:

[Serializable]
public class GameInputData
{
   public List<CommandLog> CommandLog;
}

Now that we are done with the saving and loading, let’s see our final class that is responsible for our inputs.

The Inputs class

Our Inputs class will contain all our input methods. This class will be very different for each game, but will have some common things in each implementation.

The first is a way to indicate our input mode. We can be using a normal input mode, recording or replaying. For this reason we define an enum with these modes inside our class, which will be exposed in the Unity editor:

private enum InputMode
    {
        Normal,
        Record,
        Replay
    }

The second will be a way to save our data in recording mode. Here I have implemented this, when we quit our game:

private void OnApplicationQuit()
    {
        if(inputMode == InputMode.Record)
            _gamePlayCommandsIO.Save(_invoker.CommandsLog);
    }

The third is a way to actually accept inputs from the player. In this example, I have created a method, that has the same inputs as our original script, only this time for each input, sets the relevant command in the invoker and then calls the invoker’s execute method:

private IEnumerator ReplayInput()
    {
        List<CommandLog> loadedCommands = _gamePlayCommandsIO.Load();
        int commandNumber = 0;

        while (commandNumber < loadedCommands.Count)
        {
            if (loadedCommands[commandNumber].FrameExecuted == Time.frameCount)
            {
                SetCommand(loadedCommands, commandNumber);

                _invoker.Execute();
                commandNumber++;
            }
            yield return null;
        }

        Debug.Log("Resuming normal input.");
        inputMode = InputMode.Normal;
    }

    private void SetCommand(List<CommandLog> loadedCommands, int commandNumber)
    {
        switch (loadedCommands[commandNumber].CommandType)
        {
            case CommandType.IncreaseRange:
                _invoker.Command = _increaseRangeCommand;
                break;
            case CommandType.DecreaseRange:
                _invoker.Command = _decreaseRangeCommand;
                break;
            case CommandType.ChangeColor:
                _invoker.Command = _changeColorCommand;
                break;
            case CommandType.Move:
                _moveCommand.Pos = loadedCommands[commandNumber].CommandParameters.Coordinates;
                _invoker.Command = _moveCommand;
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

We call that coroutine from our Start method, if we are in replay mode:

private void Start()
    {
        if (inputMode == InputMode.Replay)
            StartCoroutine(ReplayInput());
    }

The ReplayInput method, loops while there are still commands to be executed. If our game’s frame is the same as the frame that is recorded, then the relevant command is executed. If not, we yield for a frame.

In the end, after all recorded command have been replayed, we change our input mode to Normal so that we can continue playing the game from the state it is.

Here is the full Inputs class:

public class Inputs : MonoBehaviour
{
    private enum InputMode
    {
        Normal,
        Record,
        Replay
    }
    
    [SerializeField] private BallRefactored ball;
    [SerializeField] private InputMode inputMode;
    
    private Invoker _invoker;
    private GamePlayCommandsIO _gamePlayCommandsIO;
    private Camera _camera;
    private IncreaseRangeCommand _increaseRangeCommand;
    private DecreaseRangeCommand _decreaseRangeCommand;
    private ChangeColorCommand _changeColorCommand;
    private MoveCommand _moveCommand;

    private void Awake()
    {
        _camera = Camera.main;
        _gamePlayCommandsIO = new GamePlayCommandsIO("example");

        if(ball == null) 
            Debug.LogError("Ball is null!");

        _increaseRangeCommand = new IncreaseRangeCommand(ball);
        _decreaseRangeCommand = new DecreaseRangeCommand(ball);
        _changeColorCommand = new ChangeColorCommand(ball);
        _moveCommand = new MoveCommand(ball);

        _invoker = inputMode == InputMode.Record ? new Invoker(true) : new Invoker(false);
    }

    private void Start()
    {
        if (inputMode == InputMode.Replay)
            StartCoroutine(ReplayInput());
    }

    private void Update()
    {
        if(inputMode is InputMode.Normal or InputMode.Record)
            PlayerInput();
    }

    private void PlayerInput()
    {
        if (Mouse.current.scroll.ReadValue().y > 0f)
        {
            _invoker.Command = _increaseRangeCommand;
            _invoker.Execute();
        }

        if (Mouse.current.scroll.ReadValue().y < 0f)
        {
            _invoker.Command = _decreaseRangeCommand;
            _invoker.Execute();
        }

        if (Keyboard.current.spaceKey.wasPressedThisFrame)
        {
            _invoker.Command = _changeColorCommand;
            _invoker.Execute();
        }

        if (Mouse.current.leftButton.wasPressedThisFrame)
        {
            _moveCommand.Pos = _camera.ScreenToWorldPoint(Mouse.current.position.ReadValue());
            _invoker.Command = _moveCommand;
            _invoker.Execute();
        }
    }

    private IEnumerator ReplayInput()
    {
        List<CommandLog> loadedCommands = _gamePlayCommandsIO.Load();
        int commandNumber = 0;

        while (commandNumber < loadedCommands.Count)
        {
            if (loadedCommands[commandNumber].FrameExecuted == Time.frameCount)
            {
                SetCommand(loadedCommands, commandNumber);

                _invoker.Execute();
                commandNumber++;
            }
            yield return null;
        }

        Debug.Log("Resuming normal input.");
        inputMode = InputMode.Normal;
    }

    private void SetCommand(List<CommandLog> loadedCommands, int commandNumber)
    {
        switch (loadedCommands[commandNumber].CommandType)
        {
            case CommandType.IncreaseRange:
                _invoker.Command = _increaseRangeCommand;
                break;
            case CommandType.DecreaseRange:
                _invoker.Command = _decreaseRangeCommand;
                break;
            case CommandType.ChangeColor:
                _invoker.Command = _changeColorCommand;
                break;
            case CommandType.Move:
                _moveCommand.Pos = loadedCommands[commandNumber].CommandParameters.Coordinates;
                _invoker.Command = _moveCommand;
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

    private void OnApplicationQuit()
    {
        if(inputMode == InputMode.Record)
            _gamePlayCommandsIO.Save(_invoker.CommandsLog);
    }
}

A Note On Time.frameCount

The project uses the Time.frameCount to record the time of the commands and to replay them. This obviously makes the system frame dependent. Because I use this technique for my own projects that are developed in one machine, this doesn’t make any difference.

For a project that is going to run on different hardware, this approach is not enough. I didn’t use a different approach for the time, because this is mostly about the use of the command pattern in recording inputs.

If you want to use it for production, some changes are needed and the most important one, is another way of recording the time each input was executed. This is very game dependent, in a turn base game for example, the timer is not needed at all, you just need the turn number. In other games you may need to have a counter in the fixed update, because the fixed update tries to run a constant number of times each second, and check against that counter. For more precision, you may want to use one of the .NET timers.

You can read about all the different timers that exist in .NET in my post What are the differences in the timers in C# ?

Conclusion

This replay system can help us, by automating the need to do the same inputs in our game to check if something has been fixed in our code. It can also help, to see how our game plays out, given the same inputs. Finally, we can have test builds, that record all our players inputs, so that when a bug is reported we can actually use the saved files to have a history of the inputs that led to that bug and replay them to see the bug in action.

You can find the full source code for this post in my github repository.

I hope you enjoyed this post. Thank you for reading, and as always, if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also if you don’t want to miss any of the new blog posts, you can always subscribe to my newsletter or the RSS feed.


Follow me: