
In this tutorial, we will create a simple game in which the player can
rewind actions. We will do it in Unity, but it is possible to adapt the system to other engines. In the first part we will look at the basics of this function, and in the second we will write its backbone and make it more universal.
First, let's look at the games that use such a system. We will explore the various uses of this technique, and then create a little game with the rewind function.
')
Demonstration of key featuresYou will need the latest version of
Unity and experience with the engine. The source code will be made publicly available so you can compare your results with it.
Ready? Go!
How is this system used in other games?
Prince of Persia: The Sands of Time was one of the first games with time rewinding mechanics built into the gameplay. When a player dies, he can not only restart the game, but also rewind it a few seconds back, at the time when the character is still alive, and immediately try again.
Prince of Persia: The Forgotten Sands. The Sands Of Time trilogy has perfectly integrated time rewind into its gameplay. Due to this, the player is not interrupted for fast loading and remains immersed in the game.This mechanic is integrated not only into the gameplay, but also into the narrative and the very universe of the game, and is mentioned throughout the plot.
A similar system is used in games such as
Braid , in which the gameplay is also closely related to time rewind. The heroine of the game
Overwatch Tracer has the ability, which returns her to the place where she was a few seconds ago, that is,
rewinds her time, even in a multiplayer game. In the
GRID racing game series, there is also a snapshot mechanic: during the race, the player has a small supply of rewinds, which can be used when the car gets into a serious accident. This saves players from the annoyance that occurs during accidents at the end of the race.
In a serious collision in GRID, you have the opportunity to rewind the game at the time before the accident.Other examples of use
But this system can be used not only as a substitute for quick save. Another use is to implement ghosts in racing games and asynchronous multiplayer mode.
Replays
This is another interesting way to use the function. It is used in games like
SUPERHOT , in the
Worms series, and in most sports games.
Sports replays work almost the same way as shown on television: the process of the game is shown repeatedly, sometimes from a different angle. To do this, the games are recorded not in the video, but in the user's actions, thanks to which you can play replays from different angles and angles. In the Worms games, replays are served with humor: instant replays of very funny or effective kills are shown.
SUPERHOT also records movement. After passing the level, a replay of the entire gameplay is shown, which fits in just a few seconds.
Funny replays in Super Meat Boy. After passing the level, the player sees all previous attempts superimposed on each other.
Replay at the end of the Super Meat Boy level. All previous attempts are recorded and then played at the same time.Ghost Racing
A ghost race is a technique in which a player drives an empty track, trying to show the best time. But at the same time, he competes with a
ghost - a translucent machine, exactly the same path as the best previous player attempt. It is impossible to face it, that is, the player can concentrate on achieving the best time.
In order not to ride alone, you can
compete with yourself, which makes the time trial more interesting. This feature is used in most racing games, from the
Need for Speed series to
Diddy Kong Racing .
Race with a ghost in Trackmania Nations. This is a "silver" complexity, it means that the player will receive a silver medal if he overtakes the ghost. Notice that the models of cars intersect, that is, the ghost is not material and can be passed through.Ghosts in multiplayer modes
Another way to use the function is ghosts in multiuser asynchronous mode. In this rarely used feature, multiplayer matches are performed by recording data from one player, which is sent to another player, who then
competes with the first. The data is applied in the same way as in races with a ghost, only the competition takes place with another player.
This type of competition is used in
Trackmania games, where you can drive on various difficulties. Such recorded riders become opponents who must be defeated in order to receive a reward.
Montage of shooting
In some games, rewinding can be just a fun tool.
Team Fortress 2 has a built-in replay editor in which you can create your own videos.
Team Fortress 2 replay editor. The recorded battle can be viewed from any point of view, and not just from the eyes of the player.After enabling the function, you can record and view previous matches. It is very important that
everything is recorded, and not just what the player sees. This means that you can navigate through the recorded game world, see where everyone is
and manage time.
How to implement it
To test this system, we need a simple game. Let's create it!
Player
Create a cube in the scene, it will be a player character. Then create a new C # script called
Player.cs
and add the following to the
Update()
function:
void Update() { transform.Translate (Vector3.forward * 3.0f * Time.deltaTime * Input.GetAxis ("Vertical")); transform.Rotate (Vector3.up * 200.0f * Time.deltaTime * Input.GetAxis ("Horizontal")); }
So we can control the character with the arrows. Attach the script to the cube. Now, after clicking on Play, you can move around. Change the angle of the camera so that it looks at the cube from above. Finally, create a
floor plane and assign each object its own material so that we do not move in the void. You should have something like this:

Try to control the cube with WSAD and the arrow keys
Timecontroller
Now create a new C #
TimeController.cs
and add it to the new empty GameObject. He will manage the recording and rewind of the game.
For this to work, we will record the movement of the player’s character. After clicking the rewind button, we will change the coordinates of the character. First, create a variable that stores the character:
public GameObject player;
Assign a player object to the resulting TimeController slot so that it can have access to the player and his data.

Then you need to create an array to store player data:
public ArrayList playerPositions; void Start() { playerPositions = new ArrayList(); }
Now we need to continuously record the position of the player. We will have the saved position of the player in the last frame, the position in which the player was 6 frames back and the position where the player was 8 seconds ago (or any recording time you specified). When we press the play key, we will go back through the array of positions and assign them frame by frame, resulting in a time rewind function.
First, let's save the data:
void FixedUpdate() { playerPositions.Add (player.transform.position); }
In the
FixedUpdate()
function, we write data.
FixedUpdate()
is used because it runs at a constant frequency of 50 cycles per second (or any selected value), which allows us to write data at a fixed interval. The
Update()
function is performed with the frequency that the processor provides, which would complicate our work.
This code will save each frame in the array position of the player. Now we need to apply it!
We will add a check for pressing the rewind button. To do this, we need a boolean variable:
public bool isReversing = false;
And check in the
Update()
function:
void Update() { if(Input.GetKey(KeyCode.Space)) { isReversing = true; } else { isReversing = false; } }
To play the game
in the opposite direction , we need to apply the data instead of recording. The new code for recording and applying the player’s position should look like this:
void FixedUpdate() { if(!isReversing) { playerPositions.Add (player.transform.position); } else { player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1]; playerPositions.RemoveAt(playerPositions.Count - 1); } }
And the whole
TimeController
script will look like this:
using UnityEngine; using System.Collections; public class TimeController: MonoBehaviour { public GameObject player; public ArrayList playerPositions; public bool isReversing = false; void Start() { playerPositions = new ArrayList(); } void Update() { if(Input.GetKey(KeyCode.Space)) { isReversing = true; } else { isReversing = false; } } void FixedUpdate() { if(!isReversing) { playerPositions.Add (player.transform.position); } else { player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1]; playerPositions.RemoveAt(playerPositions.Count - 1); } } }
Also, do not forget to add to the
player
class a check on whether the
TimeController
rewinding in order to perform a motion only if it is not playing. Otherwise, the behavior may become strange:
using UnityEngine; using System.Collections; public class Player: MonoBehaviour { private TimeController timeController; void Start() { timeController = FindObjectOfType(typeof(TimeController)) as TimeController; } void Update() { if(!timeController.isReversing) { transform.Translate (Vector3.forward * 3.0f * Time.deltaTime * Input.GetAxis ("Vertical")); transform.Rotate (Vector3.up * 200.0f * Time.deltaTime * Input.GetAxis ("Horizontal")); } } }
These new lines will automatically find a
TimeController
object in the scene at startup and check it during execution. We can control the character only when rewinding is not performed.
Now we can move around the world and rewind backward movement with the space bar. You can download the package at the link at the end of the article and open
TimeRewindingFunctionality01 to check the work!
But wait, why does our simple cube player continue to look in the last direction in which we left it? Because we did not guess to record the rotation of the object!
To do this, we need another array created at the beginning to save and use data.
using UnityEngine; using System.Collections; public class TimeController: MonoBehaviour { public GameObject player; public ArrayList playerPositions; public ArrayList playerRotations; public bool isReversing = false; void Start() { playerPositions = new ArrayList(); playerRotations = new ArrayList(); } void Update() { if(Input.GetKey(KeyCode.Space)) { isReversing = true; } else { isReversing = false; } } void FixedUpdate() { if(!isReversing) { playerPositions.Add (player.transform.position); playerRotations.Add (player.transform.localEulerAngles); } else { player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1]; playerPositions.RemoveAt(playerPositions.Count - 1); player.transform.localEulerAngles = (Vector3) playerRotations[playerRotations.Count - 1]; playerRotations.RemoveAt(playerRotations.Count - 1); } } }
Try running!
TimeRewindingFunctionality02 is an enhanced version. Now our cube player can move back in time and will look exactly as it looked at the appropriate moment.
Conclusion
We have created a simple prototype of the game with an already fully working time rewind system, but it is still far from perfect. Next, we will make it much more stable and versatile, as well as add interesting effects.
Here is what we still have to do:
- Record only every 12th frame and interpolate states between recorded frames so that the data volume is not too huge.
- Record only the last 75 positions and turns of the player so that the array does not become too bulky and the game does not crash.
In addition, we will consider how to expand this system so that it acts not only for the player:
- How to record not only one player
- Add an effect that tells you to rewind (such as blurring a VHS signal)
- Use to store the position and rotation of the player's own class, rather than arrays
Unity project archive
So, we have created a simple game in which you can rewind time to the previous point. Now we can improve this function and make its use much more interesting.
Write less data and interpolate
At the moment we are recording the positions and turns of the player
50 times per second. This amount of data will quickly become overwhelming, and this will be especially noticeable in more complex games, as well as on weak mobile devices.
Instead, we can write only 4 times per second and interpolate the position and rotation between these keyframes. This way we will save 92% of performance, and the results will be seemingly indistinguishable from records with 50 frames per second, because they are reproduced in a fraction of a second.
Let's start by recording key frames every x frames. To do this, we first need new variables:
public int keyframe = 5; private int frameCounter = 0;
The
keyframe
variable is a frame in the
FixedUpdate
method in which we will write player data. Currently, it is assigned the value
5 , that is, the data will be recorded on every fifth cycle of the execution of the
FixedUpdate
method. Since
FixedUpdate
is executed 50 times per second, 10 frames will be recorded per second. The
frameCounter
variable will be used as a frame counter to the next keyframe.
Now we will change the record block in the
FixedUpdate
function to make it look like this:
if(!isReversing) { if(frameCounter < keyframe) { frameCounter += 1; } else { frameCounter = 0; playerPositions.Add (player.transform.position); playerRotations.Add (player.transform.localEulerAngles); } }
If you try to start the game now, you will see that now rewinding takes much less time. It happened so because we write less data, but we reproduce it at normal speed. Need to fix it.
First, we need another
frameCounter
variable in order not to write data, but to reproduce it.
private int reverseCounter = 0;
Correct the code that restores the position of the player to use it in the same way as we write data. The
FixedUpdate
function should look like this:
void FixedUpdate() { if(!isReversing) { if(frameCounter < keyframe) { frameCounter += 1; } else { frameCounter = 0; playerPositions.Add (player.transform.position); playerRotations.Add (player.transform.localEulerAngles); } } else { if(reverseCounter > 0) { reverseCounter -= 1; } else { player.transform.position = (Vector3) playerPositions[playerPositions.Count - 1]; playerPositions.RemoveAt(playerPositions.Count - 1); player.transform.localEulerAngles = (Vector3) playerRotations[playerRotations.Count - 1]; playerRotations.RemoveAt(playerRotations.Count - 1); reverseCounter = keyframe; } } }
Now when rewinding the player will jump to previous positions in real time!
But we didn’t really want this We need to interpolate the positions between these keyframes, which will be a bit more difficult. First, we need four variables:
private Vector3 currentPosition; private Vector3 previousPosition; private Vector3 currentRotation; private Vector3 previousRotation;
They will store the current player data and one keyframe recorded to the current data so that we can interpolate between them.
Then we need this function:
void RestorePositions() { int lastIndex = keyframes.Count - 1; int secondToLastIndex = keyframes.Count - 2; if(secondToLastIndex >= 0) { currentPosition = (Vector3) playerPositions[lastIndex]; previousPosition = (Vector3) playerPositions[secondToLastIndex]; playerPositions.RemoveAt(lastIndex); currentRotation = (Vector3) playerRotations[lastIndex]; previousRotation = (Vector3) playerRotations[secondToLastIndex]; playerRotations.RemoveAt(lastIndex); } }
It assigns the appropriate information to position and rotation variables, between which we will perform interpolation. We will do this in a separate function, because we call it for two different points.
The data recovery unit will look like this:
if(reverseCounter > 0) { reverseCounter -= 1; } else { reverseCounter = keyframe; RestorePositions(); } if(firstRun) { firstRun = false; RestorePositions(); } float interpolation = (float) reverseCounter / (float) keyframe; player.transform.position = Vector3.Lerp(previousPosition, currentPosition, interpolation); player.transform.localEulerAngles = Vector3.Lerp(previousRotation, currentRotation, interpolation);
We call the function to retrieve the last and the penultimate data set from the arrays at a given interval of key frames (in our case it is
5 ), but we also need to call it in the first cycle when the restoration is performed. Therefore, we need this block:
if(firstRun) { firstRun = false; RestorePositions(); }
To make it work, we also need the variable
firstRun
:
private bool firstRun = true;
And to reset when you release the spacebar:
if(Input.GetKey(KeyCode.Space)) { isReversing = true; } else { isReversing = false; firstRun = true; }
Here's how interpolation works: instead of just using the last saved keyframe, this system gets the last and the last but one frames, then interpolates the data between them. The amount of interpolation depends on the current distance between frames.
Interpolation is performed by the Lerp function, by which we transfer the current and previous position (or rotation). Then the interpolation coefficient is calculated, which can have values from
0 to
1 . Then the player is placed between two saved points, for example, 40% on the way to the last keyframe.
If you slow down and reproduce it frame by frame, then you can actually see how the character moves between these keyframes, but this is imperceptible during the game.
Thus, we have significantly reduced the complexity of the time rewind scheme and made it much more stable.
Record only a fixed number of frames
By drastically reducing the number of frames saved, we can now ensure that the system does not save too much data.
Now it's just a bunch of data written to the array, not designed for long-term use. As the array grows, it becomes more cumbersome and access takes more time, and the whole system becomes unstable.
To fix this, we can add code that checks to see if the array has grown larger than a certain size. If we know how many frames per second we save, we can determine how many seconds of rewinding time need to be stored so that it does not interfere with the game and does not increase its complexity. In the rather complex
Prince of Persia , the rewind time is limited to about 15 seconds, and in the more technically simple
Braid game, the rewind can be endless.
if(playerPositions.Count > 128) { playerPositions.RemoveAt(0); playerRotations.RemoveAt(0); }
When the array exceeds a certain size, we will delete its first element. Therefore, it will always store as much data as a player can rewind, and will not interfere with efficiency. Paste this code into the
FixedUpdate
function after the record and play code.
Using your own class to store player data
While we are recording the position and rotation of the player in two separate arrays. Although this works, we need to constantly remember that we write and read data from two places at the same time, which can lead to problems in the future. However, we can create a separate class for storing all this data, and in the future - for others (if necessary for the project).
The code of our own class, which will be used as a container for data, is as follows:
public class Keyframe { public Vector3 position; public Vector3 rotation; public Keyframe(Vector3 position, Vector3 rotation) { this.position = position; this.rotation = rotation; } }
You can add it to the TimeController.cs file before you start declaring classes. He creates a container to save the position and rotation of the player. The designer allows you to create it directly with all the necessary information.
The rest of the algorithm must be adapted to work with the new system. In the Start method, you must initialize the array:
keyframes = new ArrayList();
And instead of:
playerPositions.Add (player.transform.position); playerRotations.Add (player.transform.localEulerAngles);
we can save directly to the Keyframe object:
keyframes.Add(new Keyframe(player.transform.position, player.transform.localEulerAngles));
Here we add the position and rotation of the player into one object, which is then added to a single array, which significantly reduces the complexity of the algorithm.
Adding a blur effect to indicate rewind
We really need some sign that says that time is rewinding. So far, only
we know about this, and this behavior can be confusing for the player. In such situations, it is not bad to add different signals informing the player about the rewinding performed, both visual (for example, a slight blurring of the screen) and sound (slowing down and playing music in reverse order).
Let's do something in the style of
Prince of Persia by adding a little blur.
Rewind time in Prince of Persia: The Forgotten SandsUnity allows you to layered one on another several camera effects, and experimenting, you can choose the perfect fit for your project.
To use basic effects, you must first import them. To do this, go to
Assets> Import Package> Effects and import everything that we are offered.

Visual effects can be applied directly to the camera. Go to
Components> Image Effects and add
Blur and
Bloom effects. Their combination creates a good effect, to which we aspire.

These are the basic settings. You can customize them according to your project.
If you try to start the game now, it will use the effect constantly.

Now we need to learn how to enable and disable it. To do this, you need to import
TimeController
effects into
TimeController
. Add them to the very beginning:
using UnityStandardAssets.ImageEffects;
To access the camera from
TimeController
, add this variable:
private Camera camera;
And assign it a value in the
Start
function:
camera = Camera.main;
Then add this code to turn on effects when rewinding time:
void Update() { if(Input.GetKey(KeyCode.Space)) { isReversing = true; camera.GetComponent<Blur>().enabled = true; camera.GetComponent<Bloom>().enabled = true; } else { isReversing = false; firstRun = true; camera.GetComponent<Blur>().enabled = false; camera.GetComponent<Bloom>().enabled = false; } }
When you press the spacebar, now you will not only rewind time in the scene, but also activate the camera rewind effect, informing the player about what is happening.
All
TimeController
code should look like this:
using UnityEngine; using System.Collections; using UnityStandardAssets.ImageEffects; public class Keyframe { public Vector3 position; public Vector3 rotation; public Keyframe(Vector3 position, Vector3 rotation) { this.position = position; this.rotation = rotation; } } public class TimeController: MonoBehaviour { public GameObject player; public ArrayList keyframes; public bool isReversing = false; public int keyframe = 5; private int frameCounter = 0; private int reverseCounter = 0; private Vector3 currentPosition; private Vector3 previousPosition; private Vector3 currentRotation; private Vector3 previousRotation; private Camera camera; private bool firstRun = true; void Start() { keyframes = new ArrayList(); camera = Camera.main; } void Update() { if(Input.GetKey(KeyCode.Space)) { isReversing = true; camera.GetComponent<Blur>().enabled = true; camera.GetComponent<Bloom>().enabled = true; } else { isReversing = false; firstRun = true; camera.GetComponent<Blur>().enabled = false; camera.GetComponent<Bloom>().enabled = false; } } void FixedUpdate() { if(!isReversing) { if(frameCounter < keyframe) { frameCounter += 1; } else { frameCounter = 0; keyframes.Add(new Keyframe(player.transform.position, player.transform.localEulerAngles)); } } else { if(reverseCounter > 0) { reverseCounter -= 1; } else { reverseCounter = keyframe; RestorePositions(); } if(firstRun) { firstRun = false; RestorePositions(); } float interpolation = (float) reverseCounter / (float) keyframe; player.transform.position = Vector3.Lerp(previousPosition, currentPosition, interpolation); player.transform.localEulerAngles = Vector3.Lerp(previousRotation, currentRotation, interpolation); } if(keyframes.Count > 128) { keyframes.RemoveAt(0); } } void RestorePositions() { int lastIndex = keyframes.Count - 1; int secondToLastIndex = keyframes.Count - 2; if(secondToLastIndex >= 0) { currentPosition = (keyframes[lastIndex] as Keyframe).position; previousPosition = (keyframes[secondToLastIndex] as Keyframe).position; currentRotation = (keyframes[lastIndex] as Keyframe).rotation; previousRotation = (keyframes[secondToLastIndex] as Keyframe).rotation; keyframes.RemoveAt(lastIndex); } } }
Download the
package with the project and try to experiment with it.
Summarize
Our time rewinding game has become much better. The algorithm is significantly improved, consumes 90% less computing power and is much more stable. We have added an interesting effect telling the player that time is rewinding.
It is time to make a real game on its basis!