開発者コンソール
アクセスいただきありがとうございます。こちらのページは現在英語のみのご用意となっております。順次日本語化を進めてまいりますので、ご理解のほどよろしくお願いいたします。

Setting Up an Input Replay System

Written in December 2017 by Alejandro Hitti, a video game programmer and designer from Venezuela.

GameMaker is an engine that works with frames instead of delta time and thanks to this advantage, we can trivially record the player inputs and match them with a frame number. We can then simulate replaying the input and create a replay system. There are many uses for this kind of system, such as creating cutscenes, automating tests for your game to run on a loop, acting as tutorials, ghost replaying, etc.

In this post, I will go over the basics of setting up this system and leave you with ideas on how to integrate it in your game and expand it.

An example of the system being used for tutorials in HackyZack

Set Up

Here's a link to the assets I used for the demo if you want to follow along. The finished demo is also available for download at the end of the article.

We need a scene to test the input controller, so let's make a little one. We need to create three objects: oPlayer, oPot, and oWall.

The wall and the pot objects will be empty as they are used for collision and for drawing. Set the sprites to anything you want, or use the assets provided. As for the player, we need to create the four directional animations. Last, we add the tileset sprite, and create a tileset object using it.

As for the player object, we will create a simple top-down controller for it. In the Create Event, let's declare some variables:

Copied to clipboard.

/// Create Event
_kLeft   = false;
_kRight  = false;
_kUp     = false;
_kDown   = false;
_kAction = false;

image_speed = 0.5;

We will use these variables to store the input and set the animation speed to half. Next, in the Begin Step event, we will listen to the input. In a full game, it is a better idea to get the input from an input manager, not the player object itself, but that's outside of the scope of this article, so we will go with this solution.

Copied to clipboard.

// Begin Step Event
_kLeft   = keyboard_check(vk_left);
_kRight  = keyboard_check(vk_right);
_kUp     = keyboard_check(vk_up);
_kDown   = keyboard_check(vk_down);
_kAction = keyboard_check_pressed(vk_space);

Last, in the Step Event we are going to move the character, set the correct sprite, check for collision with walls, and place a Pot when we hit the spacebar (action key). This is what it looks like:

Copied to clipboard.

// Step Event

var moveSpeed = 4;

// Movement in all directions. Accepts multiple inputs.
// NOTE: Moves faster on diagonals, but that's an easy fix.
if (_kLeft && !place_meeting(x - moveSpeed, y, oWall))
{
    x -= moveSpeed;
    sprite_index = sPlayerLeft;
    image_speed = 0.5;
}

if (_kRight && !place_meeting(x + moveSpeed, y, oWall))
{
    x += moveSpeed;
    sprite_index = sPlayerRight;
    image_speed = 0.5;
}

if (_kUp && !place_meeting(x, y - moveSpeed, oWall))
{
    y -= moveSpeed;
    sprite_index = sPlayerUp;
    image_speed = 0.5;
}

if (_kDown && !place_meeting(x, y + moveSpeed, oWall))
{
    y += moveSpeed;
    sprite_index = sPlayerDown;
    image_speed = 0.5;
}

// Go back to the idle position in the animation and stop it when there are no inputs
if (!_kLeft && !_kRight && !_kDown && !_kUp)
{
    image_index = 1;
    image_speed = 0;
}

// Place a pot when the action key is pressed
if (_kAction)
    instance_create_layer(x, y, "Pots", oPot);

With that out of the way, we can create a little play area to test our code. This is the one I came up with:

And just place wall objects where needed.

If we run the game now, we should be able to have a character moving around, playing the correct animations, and colliding with walls. Also, if you press the spacebar, you will drop a pot under the character.

Input Recorder

Create a new object called oInputRecorder and turn on the Persistent flag. Next, we will make sure there's only one copy of this object ever, so at the beginning of the Create Event we will call this script MakeUnique.

Copied to clipboard.

// MakeUnique Script

if (instance_number(object_index) > 1)
    instance_destroy();

It's a pretty simple script that ensures there's only one copy of the object it's called on. It works like the singleton pattern, but it's not quite the same, so I named it differently. Here what the Create Event looks like, and I will explain what each of the variables do below.

Copied to clipboard.

// Create Event

// Only one copy of this should be running
MakeUnique();

// Keys Enum
enum eKey
{
    LeftPressed = 0,
    RightPressed,
    UpPressed,
    DownPressed,
    ActionPressed,

    LeftReleased,
    RightReleased,
    UpReleased,
    DownReleased,

    NUM_KEYS
}

// Variables
_fileName = "Recording_" + string(room_get_name(room)) + ".txt";

// Check if currently recording or playing
_isRecording = false;
_isPlaying   = false;

// Variables to keep track of the frames
_frame = 0;
_index = 0;

// Arrays to store the keys to record, and the recorded values and frames
_input         = array_create(eKey.NUM_KEYS, false);
_inputSequence = [0, 0];

// Hotkeys to start/stop recording and playback
_kRecord = 0;
_kPlay   = 0;

There's a lot to digest here, but nothing is too complicated. First, we call the MakeUnique script as we discussed before. Next, we declare an enumerator with each of the key events we want to record. Note it is not keys, but key events, such as pressed and released. For this example, we will keep track of the four directional arrows (both on pressed and released) and the spacebar for the action, but we only need this one on pressed. The last element in the enumerator will be useful later for loops as it automatically holds the number of key events in the enum.

We then choose a _filename where we will store our recording. I made the name include the room name so it is unique per room, but you can pick anything here. This text file will be saved by default in your AppData folder. Later we have two of boolean variables (_isPlaying and _isRecording) that will check if we are currently recording input, or playing back our recorded input. The _frame variable will store the frame at which the key events happen and the _index is the position in the array where it is stored. These don't match because it is possible to have multiple inputs in the same frame. The _input array stores the state of each key. We are using an array to make the next section easier to code and make adding new key events straightforward. The other array is _inputSequence, which is a 2D array that pairs a key event with the frame when it happened. Last, we have _kRecord and _kPlay, which are used to check the state of the hotkeys to activate recording and playback.

Next, we need to check our inputs in the Begin Step event. As I mentioned before, in an actual game it is advisable to get the input from your input manager, but we will get it directly to keep it simple. We will store all the inputs we intend to record (the ones in the enumerator) inside of the _input array, and the rest in their respective variables. This is what it looks like:

Copied to clipboard.

// Begin Step Event

// Keys we want to record
_input[eKey.LeftPressed]   = keyboard_check_pressed(vk_left);
_input[eKey.RightPressed]  = keyboard_check_pressed(vk_right);
_input[eKey.UpPressed]     = keyboard_check_pressed(vk_up);
_input[eKey.DownPressed]   = keyboard_check_pressed(vk_down);
_input[eKey.ActionPressed] = keyboard_check_pressed(vk_space);

_input[eKey.LeftReleased]  = keyboard_check_released(vk_left);
_input[eKey.RightReleased] = keyboard_check_released(vk_right);
_input[eKey.UpReleased]    = keyboard_check_released(vk_up);
_input[eKey.DownReleased]  = keyboard_check_released(vk_down);

// Keys we don't want to record
_kRecord = keyboard_check_pressed(ord("Q"));
_kPlay   = keyboard_check_pressed(ord("P"));

Most of the code related to the input recording system lies within the Step Event, so I will break it up into four parts to explain each bit individually, but it all goes in the same script. If you are confused, make sure to reference the demo.

Part One

First, we need to check if the record hotkey was pressed and that we are not currently playing a recording. Then, we check if the _isRecording flag is on, which means that we are trying to stop the recording. Hence, we will write what we have recorded so far into a file. We then flip the value of the _isRecording variable.

Copied to clipboard.

// Step Event Part 1

// Turn recording on if not currently on playback
if (_kRecord && !_isPlaying)
{
    // Save the recording
    if (_isRecording)
        WriteInputRecording(_fileName);

    _isRecording = !_isRecording;
}

WriteInputRecording is a script that writes what we have stored in _inputSequence to a file. The script is quite simple: first, we open the file with the name provided to the script. We then iterate through every element in the sequence and write the frame number next to the key event. We then write a new line and keep going. Once the loop is over, we set the _inputSequence to zero to delete the data structure and clean up after ourselves. Last, we close the text file. Note I am taking advantage of how GameMaker works where we can access variables in the scope where the script is called. If you wish to call this script from another object, pass the _inputSequence as an argument.

Copied to clipboard.

// WriteInputRecording Script

var file = file_text_open_write(argument0);

for (var i = 0; i < array_height_2d(_inputSequence); ++i)
{
    file_text_write_real(file, _inputSequence[i, 0]);
    file_text_write_real(file, _inputSequence[i, 1]);
    file_text_writeln(file);
}

_inputSequence = 0;

file_text_close(file);

Part Two

For part two, we will check if we are currently recording. If we are, we will go through every key event checking if it's been triggered. When we find a match, we store the frame and the event number, advancing the index. We only advance the frame after this loop, which allows us to store multiple inputs per frame. One thing to note is that for values we want to check if they are down (such as the directional arrows) we are only storing the frame where they are first down, and when we release them. We could store each frame they are down, but this way we are making the save file a lot smaller.

Copied to clipboard.

// Step Event Part 2

// Fill out array while the game is recording
if (_isRecording)
{
    // Iterate through each key and store the input and frame. Accepts multiple inputs at once.
    for (var i = 0; i < eKey.NUM_KEYS; ++i)
    {
        if (_input[i])
        {
            _inputSequence[_index, 0] = _frame;
            _inputSequence[_index, 1] = i;
            ++_index;
        }
    }

    ++_frame;
}

If we play the game now, we can press Q and record some movement and pot placing. Once we are done, we press Q again to stop recording and store the information in the file which should look something like this:

Part Three

If you got everything working until this point, we can start working on the playback functionality. The third part of the Step Event involves checking the playback hotkey (if not already recording), and if the flag is on, then we read the file and store all the information in our _inputSequence array. This is so we only have to open and read the file once since opening files is an expensive operation.

Copied to clipboard.

// Step Event Part 3

// If the play hotkey is pressed and we are not recording, start playing
if (_kPlay && !_isRecording)
{
    _isPlaying = !_isPlaying;

    // Load up all the recording sequence from the file into memory
    if (_isPlaying)
        ReadInputRecording(_fileName);
}

As you can see, we have another script here to read the file. These are the contents of that script:

Copied to clipboard.

// ReadInputRecording

var file = file_text_open_read(argument0);

var i = 0;

while (!file_text_eof(file))
{
    _inputSequence[i, 0] = file_text_read_real(file);
    _inputSequence[i, 1] = file_text_read_real(file);
    file_text_readln(file);

    ++i;
}

file_text_close(file);

It's the complementary script to the WriteInputRecording one, where we instead open the file and store all the values in the file into our array. We then close the file and prepare to run the key events.

Part Four

In the fourth and last Step Event section, we will simply check if the _isPlaying flag is active, and calling a script to run the key events if it is.

Copied to clipboard.

// Step Event Part 4

// Runs every frame to play the sequence when needed
if (_isPlaying)
    PlayInputRecording();

Nothing much to see here, so let's get into the PlayInputRecording script.

Copied to clipboard.

// PlayInputRecording Script

var player = instance_find(oPlayer, 0);

if (player != noone)
{
    player._kAction = false;

    while (_index < array_height_2d(_inputSequence) && _inputSequence[_index, 0] == _frame)
    {
        switch (_inputSequence[_index, 1])
        {
            case eKey.LeftPressed:   player._kLeft   = true; break;
            case eKey.RightPressed:  player._kRight  = true; break;
            case eKey.UpPressed:     player._kUp     = true; break;
            case eKey.DownPressed:   player._kDown   = true; break;
            case eKey.ActionPressed: player._kAction = true; break;

            case eKey.LeftReleased:  player._kLeft  = false; break;
            case eKey.RightReleased: player._kRight = false; break;
            case eKey.UpReleased:    player._kUp    = false; break;
            case eKey.DownReleased:  player._kDown  = false; break;
        }

        ++_index;
    }

    ++_frame;
}

We first get the object we want to hijack the input from (in this case the player) and we get the instance. If you are using an input manager, you should get that object instead. We then set all the key events that are triggered on pressed only (such as our action) and set them to false. We then iterate through the _inputSequence array and set the variables inside our player to the appropriate values, keeping track of our index and current frame. As you may have guessed, we also need to make a slight modification to our player Begin Step event, to prevent it from updating the values of its variables. We surround the input check section like this:

Copied to clipboard.

// Player Modified Begin Step

var recorder = instance_find(oInputRecorder, 0);

if (recorder != noone && !recorder._isPlaying)
{
    _kLeft   = keyboard_check(vk_left);
    _kRight  = keyboard_check(vk_right);
    _kUp     = keyboard_check(vk_up);
    _kDown   = keyboard_check(vk_down);
    _kAction = keyboard_check_pressed(vk_space);
}

And that should be it. All we need to do now to test our system is:

  1. Press Q to start recording.

  2. Perform a series of movements and pot placing.

  3. Press Q again to stop recording.

  4. Press F5 to restart the level to its initial state.

  5. Press P to start the playback.

You should see the player object perform the exact same moves recorded previously. To make debugging easier, I added some Draw GUI code that displays when I'm recording or playing back the sequence.

Copied to clipboard.

// Draw GUI Event

var color = c_white;
draw_text_color(5, 5, "Press Q to start/stop recording", color, color, color, color, 1.0);
draw_text_color(5, 25, "Press P to start/stop playing", color, color, color, color, 1.0);
draw_text_color(5, 45, "Press F5 to restart the room", color, color, color, color, 1.0);

var text = "";

if (_isRecording)
{
    color = c_red;
    text = "Recording";
}

if (_isPlaying)
{
    color = c_yellow;
    text = "Playing";
}

draw_text_color(5, 65, text, color, color, color, color, 1.0);

Here is our final scene.

Expanding the System

The system is very basic as it is now, but it's easy to add more to it. First, if you want to add more inputs to record, you will need to add them to the enum in the Create Event, update its value in the Begin Step either by getting the value from the input manager, or by calling the correct input function from GameMaker. Last, set the value to the appropriate value in the PlayInputRecording script. Remember that variables that only trigger once (On Pressed) should be set to false before the while loop.

Another way to expand the system would be to add mouse events, which would probably include the mouse coordinates at the time of the mouse press event. This system also works for controller inputs with no modifications, by calling the right functions.

Demo

Download the demo and project files here.