You can view your system as if it were composed of a series of states and functions, where a function f[j]
with input x[j]
changes the system state s[j]
into state s[j+1]
, like so:
s[j+1] = f[j](s[j], x[j])
A state is the explanation of your entire world. The locations of the player, the location of the enemy, the score, the remaining ammo, etc. Everything you require to draw a frame of your game.
A function is anything that may effect the world. A frame change, a keypress, a network packet.
The input is the data the function takes. A frame change may take the amount of time since the last frame passed, the keypress may include the actual key pressed, as well as whether or not the shift key was pressed.
For the sake of this explanation, I will make the following assumptions:
Assumption 1:
The amount of states for a given run of the game is much larger than the amount of functions. You probably have hundreds of thousands of states, but only a several dozen functions (frame change, keypress, network packet, etc). Of course, the amount of inputs must be equal to the amount of states minus one.
Assumption 2:
The spacial cost (memory, disk) of storing a single state is much greater that that of storing a function and its input.
Assumption 3:
The temporal cost (time) of presenting a state is similar, or just one or two orders of magnitude longer than that of calculating a function over a state.
Depending on the requirements of your replay system, there are several ways to implement a replay system, so we can start with the simplest one. I'll also make a small example using the game of chess, recorded on pieces of paper.
Method 1:
Store s[0]...s[n]
. This is very simple, very straightforward. Because of assumption 2, the spacial cost of this is quite high.
For chess, this would be accomplished by drawing the entire board for each move.
Method 2:
If you only need forward replay, you can simply store s[0]
, and then store f[0]...f[n-1]
(remember, this is only the name of id of the function) and x[0]...x[n-1]
(what was the input for each of these functions). To replay, you simply start with s[0]
, and calculate
s[1] = f[0](s[0], x[0])
s[2] = f[1](s[1], x[1])
and so on...
I want to make a small annotation here. Several other commenters said that the game "must be deterministic". Anyone who says that needs to take Computer Science 101 again, because unless your game is meant to be run on quantum computers, ALL COMPUTER PROGRAMS ARE DETERMINISTIC¹. That's what makes computers so awesome.
However, since your program most likely depends on external programs, ranging from libraries to the actual implementation of the CPU, making sure that your functions behave the same between platforms may be quite difficult.
If you use pseudo-random numbers, you can either store the generated numbers as part of your input x
, or store the state of the prng function as part of your state s
, and its implementation as part of function f
.
For chess, this would be accomplished by drawing the initial board (which is known) and then describe each move saying which piece went where. This is how they actually do it, by the way.
Method 3:
Now, you most likely want to be able to seek into your replay. That is, calculate s[n]
for an arbitrary n
. By using method 2, you need to calculate s[0]...s[n-1]
before you can calculate s[n]
, which, according to assumption 2, may be quite slow.
To implement this, method 3 is a generalization of methods 1 and 2: store f[0]...f[n-1]
and x[0]...x[n-1]
just like method 2, but also store s[j]
, for all j % Q == 0
for a given constant Q
. In easier terms, this means that you store a bookmark at one out of every Q
states. For example, for Q == 100
, you store s[0], s[100], s[200]...
In order to calculate s[n]
for an arbitrary n
, you first load the previously stored s[floor(n/Q)]
, and then calculate all the functions from floor(n/Q)
to n
. At most, you will be calculating Q
functions. Smaller values of Q
are faster to calculate but consume much more space, while larger values of Q
consume less space, but take longer to calculate.
Method 3 with Q==1
is the same as method 1, while method 3 with Q==inf
is the same as method 2.
For chess, this would be accomplished by drawing every move, as well as one in every 10 boards (for Q==10
).
Method 4:
If you want to reverse replay, you can make a small variation of method 3. Suppose Q==100
, and you want to calculate s[150]
through s[90]
in reverse. With the unmodified method 3, you will need to make 50 calculations to get s[150]
and then 49 more calculations to get s[149]
and so on. But since you already calculated s[149]
to get s[150]
, you can create a cache with s[100]...s[150]
when you calculate s[150]
for the first time, and then you already s[149]
in the cache when you need to display it.
You only need to regenerate the cache each time you need to calculate s[j]
, for j==(k*Q)-1
for any given k
. This time, increasing Q
will result in smaller size (just for the cache), but longer times (just for recreating the cache). An optimal value for Q
can be calculated if you know the sizes and times required to calculate states and functions.
For chess, this would be accomplished by drawing every move, as well as one in every 10 boards (for Q==10
), but also, it would require to draw in a separate piece of paper, the last 10 boards you have calculated.
Method 5:
If states simply consume too much space, or functions consume too much time, you can create a solution that actually implements (not fakes) reverse replaying. To do this, you must create reverse functions for each of the functions you have. However, this requires that each of your functions is an injection. If this is doable, then for f'
denoting the inverse of function f
, calculating s[j-1]
is as simple as
s[j-1] = f'[j-1](s[j], x[j-1])
Note that in here, the function and input are both j-1
, not j
. This same function and input would be the ones you would have used if you were calculating
s[j] = f[j-1](s[j-1], x[j-1])
Creating the inverse of these functions is the tricky part. However, you usually can't, since some state data is usually lost after each function in a game.
This method, as is, can reverse calculate s[j-1]
, but only if you have s[j]
. This means that you can only watch the replay backwards, starting from the point at which you decided to replay backwards. If you want to replay backwards from an arbitrary point, you must mix this with method 4.
For chess, this cannot be implemented, since with a given board and the previous move, you can know which piece was moved, but not where it moved from.
Method 6:
Finally, if you can't guarantee all your functions are injections, you can make a small trick to do so. Instead of having each function return only a new, state, you can also have it return the data it discarded, like so:
s[j+1], r[j] = f[j](s[j], x[j])
Where r[j]
is the discarded data. And then create your inverse functions so they take the discarded data, like so:
s[j] = f'[j](s[j+1], x[j], r[j])
In addition of f[j]
and x[j]
, you must also store r[j]
for each function. Once again, if you want to be able to seek, you must store bookmarks, such as with method 4.
For chess, this would be the same as method 2, but unlike method 2, which only says which piece goes where, you also need to store where did each piece came from.
Implementation:
Since this works for all kinds of states, with all kinds of functions, for a specific game, you can make several assumptions, that will make it easier to implement. Actually, if you implement method 6 with the entire game state, not only you will be able to replay the data, but also go back in time and resume playing from any given moment. That would be pretty awesome.
Instead of storing all the game state, you can simply store the bare minimum that you require to draw a given state, and serialize this data every fixed amount of time. Your states will be these serializations, and your input will now be the difference between two serializations. They key for this to work is that the serialization should change little if the world state changes little as well. This difference is completely inversible, so implementing method 5 with bookmarks is very possible.
I've seen this implemented in some major games, mostly for instant replaying of recent data when an event (a frag in fps, or a score in sports games) occurs.
I hope this explanation wasn't too boring.
¹ This doesn't mean some programs act like they were non-deterministic (such as MS Windows ^^). Now seriously, if you can make a non-deterministic program on a deterministic computer, you can be pretty sure you will simultaneously win the Fields medal, Turing award and probably even an Oscar and Grammy for all that's worth.