Trending
Opinion: How will Project 2025 impact game developers?
The Heritage Foundation's manifesto for the possible next administration could do great harm to many, including large portions of the game development community.
This article uses the development of a PS2 racing game to show how to succeed in making a replay system. It provides advice applicable for any kind of game and helps you avoid big risks early in the project by choosing the right approach for the replay system. You will learn how to handle a very small memory budget and how to avoid loosing precious time debugging your replay system at the end, by creating the right debugging features.
Having a replay feature in a game can definitely enhance the player experience. It's an opportunity to show your polished graphics from a different view, it gives the player more time to appreciate how intelligent and naturally your AI behaves, and it's a component that can enhance the gamer's experience.
During the past year I worked on a racing game for PS2 called Cyclone Circus at Playlogic Games Factory, a two-year-old company in the Netherlands. The game is based on vehicles propelled by wind which progress in complex levels by jumping, flying, and doing stunts in the air--which makes for some unique gameplay (see Figure 1).
My main responsibilities for this project were to design and implement a complete AI solution adapted to the game, handling all the tasks concerning the animation of vehicles and characters, and to make significant contributions to the gameplay. Relatively late in the project, the replay feature lay implemented, and no one had yet put any thought into it at that time. For that matter, no one on the project had concrete experience implementing a replay system, and nobody was even sure it could be done, since we were already short on memory and we hadn't designed the game to support a replay system. Still, the decision had been made to keep this feature in the game, and we had no other choice but to implement it.
I took the responsibility of making the core replay system, trying to validate that it could be done in spite of the project constraints. In our case, we needed to provide a replay system for a PS2 racing game, but the techniques described in this article can be applied to any game genre on any console, as the methodology we used is quite common for different games.
The main goal of this article is to provide a good insight of how to make a replay system, how to avoid risks by knowing the problems you will face earlier in the project, how to better estimate how much memory your replay system will need, and how much time needs to be allocated during the planning phase. Finally, I'll explain how to save huge amount of debugging time by developing the right tools for the task.
Deterministic Vs. Saving The State
So what are the different approaches we can take to implement a replay system? Imagine a crash of two cars occurring in the street--and that we want to reproduce the scene, making a movie out of it. The first solution is to ask two stuntmen in two new cars to drive the same way, and to turn, accelerate and brake the way original drivers did. If a given number of conditions are met--having almost identical cars, the same traffic conditions alongside the two vehicles, and so on--we can expect that the replay crash will look almost like the original one. The second solution would be to log the important details every few seconds: the position of each car, the position and orientation of all wheels, the state of the body of the car, and the state of any damage--then use this information to reproduce the scene again every few seconds. I suppose you get the point that the second approach is not well-suited for this purpose and would need to store a lot of information about the state of the car at frequent intervals to reproduce the scene accurately.
In the context of a game replay system, the first solution uses a deterministic approach. It's based on the fact that if only a restricted number of variables are recorded, like the state of the game controller, if initial conditions are the same, and if the code behavior is deterministic, then the simulation will be exactly the same again over time. In this case, only initial conditions and a limited number of variables need to be saved. The second approach, previously illustrated by the crash example, would be implemented by saving the full state of one or many objects in a game each frame.
Here we can already show off the first big risk for a replay system: either you put in the energy needed to make your game engine deterministic, or you will have to put in the energy needed to minimize the amount of data used to save the state of the game objects each frame. For most games, there are no other alternatives. Even worse, before you choose between the two options, I would like to suggest that the second alternative is almost never an option for any complex game. Allow me to convince you.
Memory Requirements
How many frames are there in a usual game that must be reproduced in a replay? It depends, of course, on the frame rate and the game duration. Let's take our example of a racing game on PS2 to get some concrete numbers: if the average frame rate is 60Hz and the average duration of a race is 300 seconds, we will need to generate 18,000 frames to reproduce the gameplay. For some game genres, the game duration might even be a lot longer.
If you have worked on a PS2 game, then you know how hard it is to fit everything into about 32MB of memory. We could not afford to reserve more than 1MB for storing the replay in any case, and we preferred to use less than 500K. If we were to use 30 bytes per frame, we would need 540,000 bytes to regenerate those 18,000 frames-exceeding our desired memory budget.
Again, let's provide some concrete numbers about how much memory would be needed in our project to save the full state (what we call a "snapshot") of the game at any given moment. For each vehicle we had to save its physics properties, like inertia matrices, velocity vector, frame matrix, wheels grip, vehicle and character animation state, AI parameters, scores, current and active bonuses, and so on. Storing the state of one vehicle requires something like 1K. We could optimize this by a factor of 2/3 or so, but not easily. In our game, a race has six vehicles. Storing the state of the circuit took another 4K. We ended up with a snapshot that required approximately 10K to store the state of the race.
It's clear that storing a 10K snapshot per frame is impractical. It would require about 200MB to store 18,0000 frames. Even on a PC game, it's better to avoid allocating so much memory and accessing the hard disk so extensively during replay. I hope I have convinced you that the deterministic approach is the proper route to take.
Pros And Cons
Each approach has some positive and negative consequences that we need to keep in mind. We already know that the deterministic approach usually takes a lot less memory than the snapshot approach, because only a few variables are saved in order to do the simulation step from the previous state to the new one. However, one drawback to this method is that we can't jump from one point to another in the replay. The only way is to compute all the simulation steps in between. Later in this article, I'll explain how to jump to some point in the replay using a deterministic approach as shown on the replay interface in the Figure 2.
Another drawback to the deterministic approach is that if any aspect of the game is not deterministic, then the result of the simulation step might differ from what transpired in the actual game, and the full simulation could deviate after just a few frames. Even the compression of a float value at the wrong moment could make a difference of a small decimal in the replay, and make vehicles follow a completely different trajectory. Particularly with physics, a very small change in position can introduce a completely different collision response, followed by a completely different trajectory. But that's just one part of the challenge of making a replay system and a deterministic game.
Identifying The Deterministic Part Of Your Game
To make a deterministic game, usually you don't have to concentrate on all parts of your game. Knowing the exact border of the deterministic parts can save you a huge amount of time. Let's take a few examples. In many games the sound engine does not need to be deterministic, because sound will never influence back any game object. An exception, of course, is if your sound engine sends back messages at some given time in the playback that triggers different behaviours. On the other hand, in our project, it was important that some animations are replayed exactly the same way, because sometimes a behaviour was triggered when the end of an animation was reached. This makes the animation system a part that needs to be deterministic as well.
Special effects and the rendering parts usually don't need to be deterministic, and it's advisable to keep them out of the deterministic border. Keep in mind, for example, that you will certainly want to change your camera perspective for the replay, which means you are not going to display the exact same picture again. It's also better to have your special effects optimized for the new camera position in the replay. If your special effects don't influence your game and if it's not important for the game to replay them at the exact same position, then you should put them outside the deterministic border. If you decide that the rendering should not be completely deterministic, then you should be careful that this rendering part has no influence to any element inside the deterministic border. For example, if you have game object behaviors being activated when entering the view frustum and you do a replay from a different camera, then you should expect that your game won't be deterministic anymore (See Figure 3).
Finally, expect to have a few non-obvious situations that could make your replay non-deterministic. For example, it is imperative that your physics engine is deterministic. It's better to pay attention to it as soon as possible in the project to avoid being stuck later because of the approach taken so far. Pay particular attention if your physics engine uses a cache system. In the replay, the cache will need to be in the exact same state as it was during the game. Having 1MB needed to save the cache state and restore it is certainly unacceptable.
Let's now describe the two most common tasks needed to make a game deterministic. Variability is usually introduced in a game by computing random values, as well as by the inputs from the player. To make a replay, the same sequence of random values needs to be reproduced again, along with the inputs from the player. To handle random values, you must not forget to restore the seed, so that you get the same sequence again in the replay. And the deterministic parts of your game should use a different random-number generator than the non-deterministic parts. For example, if your special effects need some random values, they should not influence the sequence of random values taken by the deterministic part of the game. Different implementations of random number generators can be found easily on the web.
Most of the games also require that you save the delta time for each frame. Usually updating the game with a step of 20ms and again with a second step of 20ms doesn't have the same result as updating your game with one step of 40ms. This is often the case because of the way physics engines handle penetrating objects. If special care has been taken from the start to ensure the game is deterministic independently of the update step durations, then you are lucky and you can rely on this to do a replay at a different frame rate. Otherwise, we usually just skip frames in order to synchronize the replay results if the replay frame rate is not exactly the same as the frame rate during the game.
Saving Replay Information
Before we study how to save replay information, we said earlier that a major drawback of saving only delta information is that we won't be able to skip a part of the replay. In our case, we wanted to be able to jump to a few sections in the replay. To do this we need to combine saving delta information with saving the full state of the game a few times-not unlike the structure that MPEG encoding uses.
We found it very handy to use one big memory stream to store replay information (see listing 1). For example, each time a minute of the game elapsed; we store the full state of the game and also store the position in the memory stream at this point. Using a memory stream makes it possible to avoid using a predefined data structure to store information, and also allows interleaving different types of information in the same stream, so long as you read it back in the correct manner.
//// The TMemoryStream class uses a block of memory instead of a file// but implement IStreamReader and IStreamWriter interfaces.// class TMemoryStream : public IStreamReader, public IStreamWriter{public:TMemoryStream();virtual ~TMemoryStream();// IStreamReader / IStreamWritervirtual int ReadBytes(void* pBuffer, int numBytes);virtual void WriteBytes(const void* pBuffer, int numBytes);// Seek methodsbool SetPos(int nPos);int GetPos();// PreAllocationvoid PreAllocate(int nSize);void AllocationStep(int nStepSize);long GetAllocatedSize();// This method uses ReadBytes to see if the current value of the// variable is the same as the one previously stored in the memory// stream. Otherwise it adds a line to the log with information about// the variable name, the given filename, and both current value and// previously stored value.bool LogAssertValue(const void* value, size_t nSize,const char* name, const char* filename = NULL);};// This macro is used to easily give information about the variable name// and the filename needed for the log if the variable doesn't have the// value that was previously stored.#define AssertValue(x) LogAssertValue(&x, sizeof(x), #x, __FILE__) |
Lisiting 1. MemoryStream class header |
To save the full state of the game we introduce two methods for each object called SaveState and RestoreState, both of which include a reference to the memory stream. The SaveState method writes all needed information in the stream so that the RestoreState method will be able to read back this information later on to restore the object in its previous state (See listing 4).
Besides this, we have to save the delta time and the state of the input controller each frame (We will detail this later). The update method of your game should be something like this:
When Recording:
Save delta time
Read input controller
Save input controller state
Update game
if one minute elapsed save full state of the game
During replay….
Restore delta time
Restore input controller state
Update game with restored delta time
If asked to jump in the replay, set position in memory stream and restore full state of the game
Drastic Optimization For Delta Information
The only information that needs to be saved each frame for a replay based on a deterministic approach is the delta time for each frame (unless the game's frame rate is fixed) and the state of all input controllers. This is the first place you should look to optimize the memory taken by the replay (See Figure 3).
In a first-draft approach, we could save a float value for the delta time, a float value for each analog input value, and a Boolean value for each input button. Let's evaluate the size needed to store the state of a PS2 gamepad with this approach. There are eight analog buttons, six digital buttons, and four analog axis. In other words, we would need to store 12 float values and 6 Boolean values. Storing a float value takes 4 bytes and all 6 Boolean values can be stored on one byte. Even then, this will require 49 bytes (4 × 12 + 1) to store for each frame, and double that in two-player mode. Since we said earlier that our budget was ideally lower than 20 bytes per frame, we must optimize this data storage in a more drastic way.
We can save a lot of memory by knowing the purpose of each button in our game, instead of storing the full gamepad in a generic way. I would definitively advise you to do some game-specific optimization here, and to compress information after interpreting the mapping of your input controller. In this case you can save only the information you really use for your game. Many buttons are only used as digital ones even if they are physically analog. Even if you use analog buttons, how many levels do you really need? Do you need a float, 256 levels, or would even 16 levels be enough for some features? That's really the place were you shouldn't waste any byte--or even any bit--of memory.
Let's take the optimization even further. How many times does a player press or release a button? Certainly not 60 times per second, and much of the time the player will only be able to press or release one button per frame. So you can likely save only information when the state of a button has changed, and minimize the data stored if nothing has changed. The same can be done for analog values. Most of the case, an analog stick will just stay at a given value--usually an extreme value or the central value. So storing the delta of the analog value also helps save memory, and optimizing the amount of data stored when the value did not change will help you optimize even further.
Finally, you can also compress the delta of an analog value in the range 0 to 255 to three bits by using a table like [-128, -48, -12, -4, +4, +12, +48, +128]. Then during the game you also force the delta of the input analog value to fit one of these values, to save the exact same value for the replay as was used during the game. This is usually an acceptable compression since using big values allows for fast reaction when moving over the full range and using small values allows a precision of 4 units to be reached over two or three frames. We used this compression scheme for features like steering and pitching on the analog stick without any noticeable effect on gameplay.
When dealing with data being stored in only a few bits, or even encoded with a variable number of bits, I strongly advise you to create a small class named something like BitPacker. Imagine this class as a stream of bits instead of bytes, where you have methods for saving a boolean value taking the next bit, then saving an integer value taking the next three bits, and so on (see Listing 2). In the end, you call a Write method with a reference to the stream used for the replay, and a few bytes will be written to encode your bit level information. This will make your code a lot cleaner and you won't have to deal with bit alignment or fixed bit structures.
//// Container to store multiple bit information in one or more bytes.//class TBitPacker{public:TBitPacker();virtual ~TBitPacker();// Clear all information to get an empty container.void Clear();// Add a boolean information.void AddBit(bool bValue);// Add an integer stored on a given number of bits only.void AddInteger(int nValue, int nBitCount);// Get the next boolean information and read the next byte// from the given stream if needed.bool GetBit(IStreamReader& stream);// Get the next integer and read the next bytes from the// given stream if needed.int GetInteger(int nBitCount, IStreamReader& stream);// Get the number of bytes needed to save the container content.int GetSizeToWrite();// Get the number of bits stored in the container.int GetBitStoredCount();// Write the content of the container to the stream, by saving// the number of bytes needed for the container content.void Write(IStreamWriter& stream);}; |
Listing 2. Header of the BitPacker class to simplify bit level coding of information. |
Figure 3 shows how we used this class and the way replay information was stored each frame. We use a bit for each analog value to indicate if a delta value is encoded or if the value reminds unchanged. We also use a bit to indicate if there is one more boolean value that need to be inversed, in which case we encode the index of the boolean variable. Using these tricks we save only a very small amount of data when values stay unchanged--in other words, for most cases.
Finally by applying the optimizations described above and detailed in the listing 3, we could save the full state of a PS2 input controller in an average of 1.2 bytes. We achieved a compression ratio of more than 1:40 mainly because most of the time the input controller varies little from the state of the previous frame. Adding one byte to store the delta time for the frame update gives us a total of 2.2 bytes per frame. Multiply this by 18,000 frames, and our storage requirement is now around 39.6K for one player, or 61.2K for two players--a huge savings over our initial budget. Even if we save 6 full states of the game, to enable jumping to different sections during the replay, we still use less than 150K of memory.
//// To make the code as simple as possible, we// only save two boolean and one analog// values from the input controller. In the article// however, explanations and memory size are based// on a real case with much more saved values.//void InputControllerUpdate(RwReal deltaTime){ TMemoryStream* pMemStream = GetReplayStream();// Store the previous state of all input valuesunsigned char nPreviousSteer = m_nSteer;bool bPreviousJumpPressed = m_bJumpPressed;bool bPreviousOpenWingsPressed = m_bOpenWingsPressed;// Replay system need to store and restore the// exact same input controller stateif (g_bRecord){// Update all analog and boolean input values// based on the input controllerm_nSteer = FloatToUnsignedChar(m_pControllerBinding->Steer.GetValue());m_bJumpPressed = m_pControllerBinding->Jump.BecamePressed();m_bOpenWingsPressed = m_pControllerBinding->OpenWings.BecamePressed();// Compress the state difference of the input//controller compared to the previous frameTBitPacker bitPacker;// For each changing boolean input value, we store a bit with value true,// followed by the index of the changed value.if (m_bJumpPressed != bPreviousJumpPressed){ bitPacker.AddBit(true); bitPacker.AddInteger(0,4); }if (m_bOpenWingsPressed != bPreviousOpenWingsPressed){ bitPacker.AddBit(true); bitPacker.AddInteger(1,4); }// A bit with value false is added to indicate the end.bitPacker.AddBit(false);// Based on the previous and new analog values, we find the// compressed delta that get the value nearer from the goal.// We first store a bit to indicate if we store a delta or if we// just keep the previous value because the delta is too small.int nDeltaSteer = DeltaCompress(nPreviousSteer, m_nSteer); bitPacker.AddBit(nDeltaSteer != -1);if (nDeltaSteer != -1) bitPacker.AddInteger(nDeltaSteer, 3);// Save the packed delta information to the memory stream// You can log the value of bitPacker.GetSizeToWrite() to see// how many bytes are needed to save the input controller state.bitPacker.Write(*pMemStream);}else if (g_bReplay){TBitPacker bitPacker;// As long as we read a bit with value true, we read an index// stored on 4 bits that indicates the boolean input value that // has changed. We inverse the boolean state of this variable.while(bitPacker.GetBit(*pMemStream)){int nVariableIndex = bitPacker.GetInteger(4, *pMemStream);switch(nVariableIndex){case 0: m_bJumpPressed = !m_bJumpPressed; break;case 1: m_bOpenWingsPressed = !m_bOpenWingsPressed; break;}}// Test if a compressed delta has been stored for the steer valueif (bitPacker.GetBit(*pMemStream)) {m_nSteer = DeltaDecompress(nPreviousSteer, (unsigned char)(bitPacker.GetInteger(3, *pMemStream)));}}// Do update here according to the inputs...} |
Listing 3. Structure used to compress and restore the InputController state. |
How To Guarantee Your Game Is Really Deterministic
Making a game fully deterministic is not an easy task, and the amount of time needed to test and debug non-deterministic issues could take you months without some tools to help you out. Finding the last few bugs is usually quite challenging, since they are often difficult to reproduce and detect. Just looking at the replay is a poor approach to see if something replayed differently. First, you will miss a lot of subtle differences even if you watch it very carefully. And even if you see that the replay simulation deviates, you won't have precise information about the cause of deviation--the first variable that was different between the gameplay and the replay. Believe me that spending some time here to create some better debug tools will save you a huge amount of time and could be what lets you keep the replay feature.
We saw earlier that we can either save the full state of the game object by calling a function like SaveState, or that we can save the delta information for each frame containing information about the delta time and the input controllers. Let's introduce a new REPLAY_ASSERT mode in the game that we will use when debugging the replay. In this mode, we also save the delta information just as in the normal mode at the start of each frame, then do the full simulation update and at the end of each frame we save the full state of the game. In other words, in the same memory stream we will alternate saving delta information and saving full state of the game continuously each frame.
During a replay in this assert mode, we will restore delta information the normal way, then we do the full simulation update and at the end of the frame we compare the full game state with the state previously saved at the end of the frame. If the full state is exactly the same as the one we saved previously, it means the frame has been replayed and all saved variables have evolved exactly the same way. If after a frame any deterministic variable is different, then we know that the variable has evolved differently during the last update, even if all deterministic variables were still exactly identical when starting the frame update. This means that we have to look at how this variable is updated and its dependencies with any non-deterministic variable. Perhaps we forgot one variable that should be deterministic as well.
The comparison between the two full states can be done by introducing a function AssertState beside each function SaveState as illustrated in Listing 4. In this method we will log any variable which has different content and display information about the class or file where the variable is defined. Looking at this log will give us immediate information about which variable is not deterministic, and thus the source of deviation in the replay.
// This method saves the state of an object to the memory stream.void SaveState(TMemoryStream& MemStream){MemStream.WriteValue(m_vLastPosition);MemStream.WriteValue(m_fLastProgression);MemStream.WriteValue(m_fHeightInTrack);// Save AiVehiclem_pAiVehicle->SaveState(MemStream);}// This method restores the state of an object from the memory stream. void RestoreState(TMemoryStream& MemStream){MemStream.ReadValue(m_vLastPosition);MemStream.ReadValue(m_fLastProgression); MemStream.ReadValue(m_fHeightInTrack);// Restore AiVehiclem_pAiVehicle->RestoreState(MemStream);}// This method assert the state of an object is the same as the one// described in the memory stream and log differences.void AssertState(TMemoryStream& MemStream){MemStream.AssertValue(m_vLastPosition);MemStream.AssertValue(m_fLastProgression);MemStream.AssertValue(m_fHeightInTrack);// Restore AiVehiclem_pAiVehicle->AssertState(MemStream);} |
Lisiting 4. Methods to Save, Restore and Assert the state of an object. |
Don't forget that this approach saves the full state of the game each frame, and so you should expect a few hundred megabytes of memory to be used. That's usually not a problem if you can debug your game on PC. If you must debug on the console, you will have to play for a short time only, or regularly save data on a local or remote hard disk. Also, expect some acceptable frame rate drop when using this replay assert mode.
Using this approach we managed to keep the game deterministic, without spending too much time tracking down the source of bugs. Don't get me wrong, non-deterministic issues are not always easy to fix in a game, but with such a tool it becomes a lot easier. You should fully expect to be faced with a list of bugs that are difficult to track and reproduce--just as we did--and that's when you will be happy to have spent the time creating this debugging tool.
Conclusion
As you can see in this article, the task of implementing a replay system for a game is quite risky. There aren't many approaches that can be used, and in any case it's hard to stick to a low memory budget for storing the replay data. Making a game deterministic relatively late in the project is quite challenging and often requires a code review of many parts of the game. It's also a difficult task to find which parts of the game need to be deterministic, and which variables need to be saved within the state of an object.
I have shown you how we implemented a replay system for a PS2 title with drastic memory constrains. It took us about two weeks to implement the core replay system, and another two weeks to debug and solve the deterministic issues. Many other decisions would have brought us to a dead-end. I hope this article will help you make good choices when implementing your own replay system, and avoid bad surprises late in the game. In the end we were really happy to see a perfectly deterministic replay working on PS2, all fitting in a small memory budget of only 150K of memory.
For More Information
This article does not cover how to design good camera for replay but I can refer you to [Kharkar01] for an article covering this subject. There was also no space to discuss how to select the best camera and how to identify the best sequence to show in a replay. This could be the purpose of an other article.
[Kharkar01] Sandeed V.Kharkar, "Camera AI for Replays", AI Game Programming Wisdom, pp.472-478, Charles River Media, 2002.
______________________________________________________
Read more about:
FeaturesYou May Also Like