In this tutorial, we'll build a simple game where the player can rewind progress in Unity (it can also be adapted to work in other systems). This first part will go into the basics of the system, and the next part will flesh it out and make it much more versatile.
First, though we'll take a look at what games use this. Then we'll look at the other uses for this technical setup, before ultimately creating a small game that we can rewind, which should give you a basis for your own.
You will need the newest version of Unity for this, and should have some experience with it. The source code is also available for download if you want to check your own progress against it.
Ready? Let's go!
How Has This Been Used Before?
Prince of Persia: The Sands of Time is one of the first games to truly integrate a time-rewinding mechanic into its gameplay. When you die you do not just have to reload, but can rather rewind the game for a few seconds to where you were alive again, and immediately try again.
This mechanic is not only integrated into the gameplay, but the narrative and universe as well, and is mentioned throughout the story.
Other games that employ these systems are Braid, for example, which is also centered around the winding of time. The hero Tracer in Overwatch has a power that resets her to a position a few seconds ago, essentially rewinding her time, even in a multiplayer game. The GRID-series of racing games also has a snapshot-mechanic, where you have a small pool of rewinds during a race, which you can access when you have a critical crash. This prevents frustration caused by crashes near the end of race, which can be especially infuriating.
Other Uses
But this system can not only be used to replace quick-saving. Another way this is employed is ghosting in racing games and asynchronous multiplayer.
Replays
Replays are another fun way to employ this feature. This can be seen in games like SUPERHOT, the Worms series, and pretty much the majority of sports games.
Sports-replays work the same way they are presented on TV, where an action is showed again, possibly from a different angle. For this not a video is recorded but rather the actions of the user, allowing the replay to employ different camera angles and shots. The Worms games use this in a humorous way, where very comical or effective kills are shown in an Instant Replay.
SUPERHOT also records your movement. When you are done playing around your entire progress is then replayed, showing the few seconds of actual movement that happened.
Super Meat Boy uses this in a fun way. When you finish a level you see a replay of all your previous attempts laid on top of each other, culminating with your finishing run being the last left standing.
Time-Trial Ghosts
Race-Ghosting is a technique where you race for the best time on an empty track. But at the same time, you race against a ghost, which is a ghostly, transparent car, which drives the exact way you raced before on your best attempt. You cannot collide with it, which means you can still concentrate on getting the best time.
Instead of driving alone you get to compete against yourself, which makes time-trials much more fun. This feature shows up in the majority of racing games, from the Need for Speed series to Diddy Kong Racing.
Multiplayer-Ghosts
Asynchronous Multiplayer-Ghosting is another way to use this setup. In this rarely-used feature, multiplayer matches are accomplished by recording the data of one player, who then sends their run to another player, who can subsequently battle against the first player. The data is applied the same way a time-trial-ghost would be, only that you are racing against another player.
A form of this shows up in the Trackmania-games, where it is possible to race against certain difficulties. These recorded racers will give you an opponent to beat for a certain reward.
Movie-Editing
Few games offer this from the get-go but used right it can be a fun tool.Team Fortress 2 offers a built-in replay-editor, with which you can create your own clips.
Once the feature has been activated you can record and watch previous matches. The vital element is that everything is recorded, not only your view. This means you can move around the recorded game-world, see where everyone is, and have control over time.
How to Build It
In order to test this system, we need a simple game where we can test it. Let's create one!
The Player
Create a cube in your scene, this will be our player-character. Then create a new C#-script calls Player.cs
and adapt the Update()
-function to look like this:
void Update() { transform.Translate (Vector3.forward * 3.0f * Time.deltaTime * Input.GetAxis ("Vertical")); transform.Rotate (Vector3.up * 200.0f * Time.deltaTime * Input.GetAxis ("Horizontal")); }
This will handle simple movement via the arrow keys. Attach this script to the player cube. When you now hit play you should already be able to move around.
Then angle the camera so that it views the cube from above, with room on its side where we can move it. Lastly, create a plane to act as floor and assign some different materials to each object, so that we're not moving it inside of a void. It should look like this:
Try it out, and you should be able to move your cube using the WSAD and arrow-keys.
The TimeController
Now create a new C#-script called TimeController.cs
and add it to a new empty GameObject. This will handle the actual recording and subsequent rewinding of the game.
In order to make this work, we will record the movement of the player character. When we then press the rewind button we will adapt the player coordinates. To do so start by creating a variable to hold the player, like this:
public GameObject player;
And assign the player-object to the resulting slot on the TimeController, so that it can access the player and its data.
Then we need to create an array to hold the player data:
public ArrayList playerPositions; void Start() { playerPositions = new ArrayList(); }
What we will do next is continuously record the position of the player. We will have the position stored of where the player was in the last frame, the position where the player was 6 frames ago, and the position where the player was 8 seconds ago (or however long you will set it to record). When we later hit a button we'll go backward through our array of positions and assign it frame by frame, resulting in a time-rewinding feature.
First, let's save the data:
void FixedUpdate() { playerPositions.Add (player.transform.position); }
In the FixedUpdate()
-function we record the data. FixedUpdate()
is used as it runs at a constant 50 cycles per second (or whatever you set it to), which allows for a fixed interval to record and set the data. The Update()
-function meanwhile runs depending on how many frames the CPU manages, which would make things more difficult.
This code will store the player-position of each frame in the array. Now we need to apply it!
We'll add a check to see if the rewind button was pressed. For this, we need a boolean variable:
public bool isReversing = false;
And a check in the Update()
-function to set it according to whether we want to rewind the gameplay:
void Update() { if(Input.GetKey(KeyCode.Space)) { isReversing = true; } else { isReversing = false; } }
To make the game run backward, we will apply the data instead of recording. The new code for recording and applying of the player 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 entire TimeController
-script 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, don't forget to add a check to the player
-class to see if the TimeController
is currently rewinding or not, and only move when it is not reversing. Otherwise, it might create buggy behavior:
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 the TimeController
-object in the scene on startup and check it during runtime to see if we are currently playing the game or rewinding it. We can only control the character when we are currently not reversing time.
Now you should be able to move around the world, and rewind your movement by pressing space. If you download the build package attached to this article and open TimeRewindingFunctionality01 you can try it out!
But wait, why does our simple player-cube keep looking in the last direction we left them in? Because we didn't get around to also record its rotation!
For that you need another array to keep its rotation-values, to instantiate it at the beginning, and to save and apply the data the same way we handled position-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 it out! TimeRewindingFunctionality02 is the improved version. Now our player-cube can move backward in time, and will look the same way it did when it was at that moment.
Conclusion
We have built a simple prototype game with an already usable time-rewinding system, but it is far from done yet. In the next part of this series we'll make it much more stable and versatile, and add some neat effects.
Here is what we still need to do:
- Only record every ~12th frame and interpolate between the recorded ones to save on the huge data load
- Only record the last ~75 player positions and rotations to make sure the array doesn't become too unwieldy and the game doesn't crash
We'll also take a look at how to extend this system past just the player-character:
- Record more than just the player
- Add an effect to signify rewinding is happening (like VHS-blurring)
- Use a custom class to hold player position and rotation instead of arrays