Recording and Playing Human Input
A few months back I wrote an Input Recorder and an Input Player for my game engine. At the time it was merely for fun, but since I started testing seriously it’s been invaluable.
I’ve separated the recording and playback into two separate components named appropriately. Before explaining how they work, let’s take a look at the World object’s update and simulate method.
The update method is the external API for telling a World to progress in time. In order to be able to work with a fixed time step, there is an accumulator which contains the time which has not been simulated yet — the simulation “debt”. After the extra time has been added to the debt, a loop calling simulateTime is engaged, which passes a fixed time step to the simulation method to decrease the debt until there is not enough time left accumulated to fill one simulation step.
In the simulate method is where the actual action happens. On line 115 it routes the delta time to all the objects belonging to the world, then on line 119 runs a collision detection on the objects, and finally cleans up any objects that has been marked for removing on line 121.
Lastly, a simulation-event is emitted and tick count increased. The tick is only there to give an exact index of a point in simulated time. It is agnostic towards the size of the simulated time step. Within a World it is expected that the time step is consistent over time. Otherwise, we would need to record this too before playing back.
The recorder is set up using an instance of World where the simulation takes place, and an input to listen to. It also sets up a listener callback.
Whenever you want to record, you run the record-method, which binds the listener callback to the trigger-event of input.
When a trigger-event is emitted an object containing tick, key, and key-state (up/down) is stored in order of occurrence.
This list of events can be exported as JSON.
The player takes the same arguments when set up. Instead of recording you can play back a log generated by the recorder.
Instead of listening to input and checking ticks, we are listening for ticks and emitting input.
When the play-method is called, we find the tick of the first log item, and bind a callback to the simulation-event on the World object. On every simulation-event, we check if that event is for the right tick, and if it is, we trigger the key and key state for that tick. Since multiple key presses might occur during the same tick, we continue checking next log event until tick is not matching.
The play-method returns a Promise that resolves once all the input has been played back. You can also stop the playback, which rejects the promise and unbinds the callback from the simulation-event.
In the first iteration, I was not using ticks. I saved the delta time of every key press. This worked quite okay, but in combination with fixed time step and counting simulation ticks, this solution at least feels much better. Also, the amount of data stored is less.
Below is a quick demo where I record input on the Heatman level and then play it back in another session.