A replay system can be an important asset to a game, especially in the shmup genre. Being able to share replay files with other people when you clear a new difficulty or want to show off a high score can add to the experience. Early in development I decided to include a replay system in my shmup. I realized pretty quick that it isn’t going to be easy to code, especially since I had been using GameMaker for only 4 weeks at that point and information on the subject was sparse.
When researching it became apparent there are two main ways to go about recording a replay.
- Save the state of all objects and variables each frame and re-apply the states to play it back
- Have a deterministic game, record and replay user inputs
It would be difficult to record the state of all objects and variables in each frame. Also performance, memory usage and replay file size would become big issues. If you do the math, 108,000 frames are in 30 minutes and a full clear replay could easily exceed that. Saving states of all variables of all instances for that many frames would become ridiculous. Because of this I decided to tackle the problem of making my game deterministic and play back recorded inputs. I also wanted to be able to resume the game before bosses on each stage so I need a limited ability to save/restore the games state, however this isn’t a full state save/restore and I won’t be getting in to it in this post.
GameMaker: Studio has a random_set_seed(val) function that when set, can guarantee you get the same results every time. This means it’s completely okay to use GameMakers random functions to generate numbers, so long as you set the seed beforehand. I made a little script in my game to do this as I don’t care about keeping visual effects in the background or particles synced.
Begin Step event of a core object so it’s executed every frame:
// Frame Counter, counts up every frame from the beginning of the game. global.frame++; global.seed = global.frame;
The script I use for ‘random’ events that I wish to be deterministic:
///random_sync(x); random_set_seed(global.seed); var r = random(argument0); /* Increment each time it's used so we don't get the same results using it multiply times in 1 frame. */ global.seed++; return r;
When I pause the game the frame counter stops and no input is recorded. Code for this is a little messy and every game seems to do pausing differently so you may need to work this out for yourself.
I have noticed GameMakers collisions are the same only 99.9% of the time. I’ve had (very rare) cases where objects register collisions 1 frame different. It’s best to write your own collision code to make sure you’re 100% deterministic.
Another GameMaker thing to keep in mind is the order of events that happen. You’ll need to be aware of what is happening when in order to synchronize everything to the frame.
As this game is locked at 60FPS and doesn’t rely on delta_time, we don’t have to worry about timing of events too much. All input playback events are dependent on the ‘global.frame’ counter variable I’m incrementing each frame, as is the random seed (shown above in the code).
The keyboard and controller ‘press’ and ‘release’ events are filtered down to single variables in an object called ‘input’. As an example, if ‘input.up’ is ‘true’ it means I could be either pressing the keyboards up arrow, up on the D-PAD or up on the thumb stick (as it is filtered down to 8-way movement). This makes recording the inputs much easier as I only have to record the changes in variable states (when you press and release keys) and the frame it changed on.
|Player Action||Variable State|
|Holding ‘up’||input.up == noone|
|Pressed ‘up’ on this frame||input.up == true|
|Released ‘up’ on this frame||input.up == false|
|Not pressing ‘up’||input.up == noone|
I record this data to an array, adding a new column on every press/release event. On each column I store the frame counter for timing and all the inputs. I’m saving the inputs to the disk as single bits so we store them as a 1 or 0. No ‘noone’ state like we get from the input object, that means another small variable filter is needed.
If you’re new to binary I suggest you read this first. The file is going to have a header containing starting information (level, lives, bombs, weapon power, position and anything else we need to restore at the beginning of the replay). After this, a for loop is used to write the frame/input data to the disk. The state of all 7 inputs can fit in a single byte and the frame counter will need 3 bytes allowing up to 16,777,216 frame long replays (77 hours). Here is a code snip of the loop saving the frame and input data.
To load the replay I’m essentially doing the same as saving it in reverse. Loading the header contents and looping through loading the frame and input data in to an array. This should go without saying but it’s important to make sure you’re reading the right bits back, it’s easy to have it 1 off with a bit of broken logic.
For controlling the character I have 2 options. I can read the input from my input object or read it from the loaded array (Code). I decide what to do based on the global.replay variable, it’s true for playing back a replay and false for user input. This is probably the simplest part of the system.
Improvements on my current system could be to record the frames between each key press rather than how many frames since the beginning. With this change you could get away with 2 bytes for storing frame data rather than 3 bytes for the frames since the beginning. 2 bytes would mean you could have just over 18 minutes between key presses without issue.
Another improvement that I already have working is resuming from points within the game. In the future I’ll be doing a write up on how I implemented this. I feel it goes beyond the scope of this post, that’s why I’ll keep it separate.
Hopefully you’ve found some useful information in this post. Feel free to ask any questions in the comments or contact me directly. I would love to see any code you come up with.
Here are some useful links, the first one helped me a great deal when I was developing my system initially: