MotoGP had the ability to store and replay race events, and could save replays to disk. Players used this to view races after they finished, and in timetrial mode to race against a "ghost bike" that was playing back their previous best lap.
There are several ways to implement replays:
- Store the entire state of the game simulation at every frame. This is simple, but can produce a huge amount of data!
- Store the game state at periodic intervals, and use some kind of curve fitting or prediction algorithm to fill in the missing frames (similar to how network prediction smooths the gaps between network packets).
- Store controller inputs, and trust the game simulation to reproduce the same results when fed these same inputs. This is a very compact representation, but has some disadvantages. You can’t fast-forward or rewind this kind of replay, because the only way to get to a specific frame is to start at the beginning and reproduce everything that happened since then. Also, this only works if your game is 100% deterministic.
MotoGP used a mixture of techniques #2 and #3. We stored the entire physics simulation state at periodic intervals, and also stored controller inputs for the gaps in between these keyframes. This hybrid approach allowed us to jump back and forth between keyframes (so we could include forward and rewind controls in the replay viewer) while keeping the overall data rate quite low.
Remember how predictable memory usage is important in console games? This applies to replay data, too. We allocated a fixed size (one megabyte) buffer for the replay system, which was enough to store a default 3 lap race with keyframes every 10 seconds. But the game also had an option that let hard-core race fanatics replicate the 20+ lap races from the real MotoGP sport. To find room for such long races, we varied the keyframe frequency:
- Compute (expected lap time * number of laps * number of bikes)
- Double it to account for bad players who might take longer than normal
- Work out how much room we need for that many controller inputs
- Divide whatever space is left in our buffer by the size of a full physics state keyframe
- This is how often to save full keyframes
In a 20 lap race with a full pack of bikes, you got no keyframes at all. You could play back the replay in order from the start, but couldn’t jump back and forth through it. In a 10 lap race, you could jump in units of roughly 1 minute. In a 1 lap race, you got a keyframe every couple of seconds. In each case we tried to fit as many keyframes as possible into memory. If you messed around and deliberately failed to finish the race in a sensible amount of time, the replay would just stop recording when the buffer filled up, aka "ran out of film for the camera".
It is important to understand that replay keyframes only stored the state of the core bike physics. They did not store any data to do with rider animations, sounds, cameras, particles, skidmarks, etc. These other features did not affect the core gameplay simulation, so they did not have to be 100% identical during replays. As long as the bikes did the same thing, these other systems would stay at least similar to the original. We didn’t care if individual particles ended up in slightly different locations, as long as there was still a cloud of smoke in the right place.
The challenge was what to do when you jumped to a different keyframe. This would leave things like particles and rider animation in the wrong state, since they were not stored as part of the keyframe. To avoid obvious artifacts during these jumps, we deleted all active particles and skidmarks, restored bike state from the keyframe, then ran a single update before rendering a frame to the screen. This update gave the camera and rider animation a chance to catch up with the modified bike position, but left us without any particles or skidmarks. If you let the replay continue forward, new particles would soon appear, but any skidmarks from earlier in the race were gone for good. Ah well, such is the price you pay for making things work with limited memory!
The most painful part of implementing replays was making sure we got the same results each time we played back the same controller inputs. This technique only works if the game simulation is 100% deterministic. Over time, even the tiniest deviation will be magnified until you end up with objects flying around in crazy directions, running into walls, etc.
We discovered (and spend much time fixing) several reasons why a game may not be entirely deterministic:
- If there are any bugs such as uninitialized variables or threading race conditions. Solution: don’t have any bugs!
- If you use variable timestep mode, and the timing is not exactly the same when the replay is recorded and played back. Solution: use fixed timestep mode.
- If your Update and Draw methods are not properly separated. In particular, if drawing modifies any state that is used by a subsequent update, and the replay happens to drop frames (which causes it to skip a draw call) in different places, this will throw it out of sync. Solution: make sure drawing never changes any data that is later used during updates.
- If you have random number generators that are seeded based on the current time, or shared between simulation and rendering code. Solution: make sure your simulation uses separate random generators from drawing, that they are explicitly seeded, and that the seeds are saved in the replay.
- If you share replay data across different platforms, compilers, or game builds, floating point precision differences can cause all kinds of badness. Solution: stay tuned for my next post..