Open a327ex opened 8 years ago
Very well-written post! I enjoyed it a lot and am thankful that you took the time to write it.
I do have a suggestion for maybe reducing the filesize of the files that you make when creating replays. Instead of storing all the state every frame in your ReplayObjects
, I would suggest dividing this load into two different sets. ReplayGameState
and ReplayGameStateUpdate
objects. The first to be essentially a mirror of the objects used in your post, while the second are just the deltas between frames. So when you initialize a new enemy / player / item for use to replay, you store all relevant character data, but in the requisite frames after that, you merely provide the fields that changed since the previous frame. Most likely the entity's x
and y
coordinates will change every frame, but their health and Layer won't necessarily change every frame (in the case of health) or potentially over the duration of the replay (in the case of Layer).
At any rate, thanks again!
@TimelyToga Hey, glad you liked the post. And yea, that seems like a good idea that I hadn't considered. The optimization steps I worked on only dealt with not saving static objects over and over, and that about halved the replay size from 10mb to 5mb per 10s. But it seems like your idea would be something much better to try and maybe then I can get replays to a reasonable size even with this technique. Thanks for the advice!
@adonaac, very good post and very learningful. I was about to suggest something the same enhancement @TimelyToga mentionned. But anyway, very good post, as usual. I am sharing this on #gamedev. Keep it up!
Thanks for great articles. Link is broken: https://github.com/adonaac/ld34
I really appreciate you writing this article. There is a lot to think about here. Thank you.
Interesting post! Thanks.
I'm working with Lua on Playdate, which is a low powered embedded device. Memory and CPU is a concern. In my game I have a fixed number of objects in each level that mostly stay still, and my system is completely deterministic. I'll look into recording and playing back inputs!
2015-12-25 12:55
In this post I'll explain how I approached creating a simple replay system using Lua. There are two main ways I know of to do this, one is storing inputs and ensuring your simulation is deterministic, another is storing all state needed every frame to play the game back. I decided to go with the second one for now because it seemed easier. The game I'll use as an example for this is the one I made during the last Ludum Dare and you can find the code for all this here.
Objects, attributes and methods in Lua
All objects in Lua are made out of tables (essentially hash tables), with attribute and method names being the keys, and values and functions being the values. So, for instance, suppose a class definition like this:
Since In Lua,
table.something
is the same astable["something"]
, accessing a key in a table looks the same as accessing the attribute of an object. So we could say that the class definition above is essentially the same as this:This property of the language has a few interesting results. For this article the one we're going to focus on in this post is the ability to add and change attributes of an object with a lot of ease. For instance, consider the slightly modified class definition:
We've added an
opts
argument there and we're doing something with it in a loop. Assumingopts
is a table, what the loop is doing is going through all of its keys and values (namedk
andv
respectively) and then assigningv
toself[k]
. What this does is that if you have aGameObject
construction call that looks like this:Now on top of
game_object.x
andgame_object.y
being100
and200
, you also havegame_object.layer
being'Default'
, andgame_object.damage
being10
. This is because the loop in the constructor went through all keys and values in theopts
table and assigned them to the object being constructed by sayingself[k] = v
.Replay State
The replay system we're building uses this idea heavily. But before we explore that I need to explain at a high level how it will work. The way we're doing replays is by storing state every frame and then when we need to play it back we just read that stored state. The easiest way I could imagine of doing this was, when replaying, having multiple instances of a class called
ReplayObject
. These objects will be responsible for reading the saved state and changing themselves (much like in theopts
example above) to match the state of some object that was saved on that frame. Then when all that is done for all objects, all replay objects will be drawn, and since their state is matching that of some original object that was recorded, it will be just like if it was the real thing.If this didn't make much sense let's try looking at a real world example. Below I have the
draw
definition of an object in my game, a particle effect for when bats spawn:Inside a
ReplayObject
we'll be reusing theBatSpawnParticle.draw
function somehow, since the replay object will be sort of mimicking aBatSpawnParticle
, and because of that we need it to have all the state that is used in that draw function. Usually this boils down to saving everything that uses an attribute fromself
, so in this case:self.x
,self.y
,self.angle
,self.m
andself.v
. And this would look like this:replay_data
is the table we're using to store everything, andreplay_data[frame]
is just another table that holds all the saved state for this frame. We'll do this for every class in the game that we're interesting in saving for playback. In any case, now that we've added all the state needed to draw this object when it's replaying, we can look at what aReplayObject
looks like.ReplayObject
At its core the replay object is very simple and it looks like this:
The single line in the draw function is the one that makes use of another object's draw function. If you remember when saving the state of
BatSpawnParticle
, I saidsource_class_name = self.class_name
. All my objects have their class name stored inself.class_name
(including ReplayObjects), sosource_class_name
stores the class name of the object that this replay object is supposed to mimick. In this case it stores the string'BatSpawnParticle'
. Another thing to note is that all my class definitions look like this:class
is a function that creates the class definition, andBatSpawnParticle
is just a variable that holds that class definition. Variables are global by default in Lua, soBatSpawnParticle
is now a global variable that holds that class definition. All global variables in Lua are stored in the table_G
, so, for instance, we can access the draw function ofBatSpawnParticle
by saying_G['BatSpawnParticle'].draw
. Again, since functions are also just key/value pairs in a table we can do that safely.Finally, one last thing about that line in replay object's draw function is that it passes
self
to the draw function. If you go back and look at theBatSpawnParticle:draw
definition it uses a:
. In Lua, that's a shorthand for passingself
as the first parameter, soBatSpawnParticle:draw()
is exactly the same asBatSpawnParticle.draw(self)
. The trick here is thatReplayObject
is passing itself asself
toBatSpawnParticle.draw
, but since it already has all the state needed by that function (since that was what was saved), the function will work properly and the replay object will be drawn as if it were aBatSpawnParticle
. This trick is what allows us to use only ReplayObjects to mimick and draw every single object in the game while replaying.Level
Finally, one last thing is needed to bring this all together, which is coordinating
ReplayObject
creation/destruction based on thereplay_data
for this frame. I have aLevel
class where all my game objects are updated, and there I can do something like this:Step 1 is important because it means we'll be reusing replay objects as much as possible. If the number of saved tables in
replay_data[frame]
is bigger than the current number of replay objects then we create new ones, otherwise we delete extra ones. This is going to be happening every frame such that at all times the number of replay objects alive is exactly the same as the number of objects saved for that frame. And that looks like this:Step 2 is needed so that we clear the state of this replay object so that it can be reused again. If we don't do this what can happen is that in one frame a certain
ReplayObject
is used as aBatSpawnParticle
and in the next it's used as thePlayer
, and since we aren't clearing it now it has state left over from when it was aBatSpawnParticle
and this can quickly lead to bugs. An easy way to clear an object is like this:We just go over all keys and values in
self
and verify that they're not things we don't want to clear, for instance, we don't want to erase the reference to theclear
function itself. So once we know it's safe we justnil
that value. This way, all attributes from a previous frame will be cleared and the object will be brand new for step 3, where we make this replay object mimick a saved object. This boils down to a single simple line:After this, we should be able to draw each replay object as if it were a normal object that we saved and so the replay system works completely. It's important to notice that what we did for
BatSpawnParticle
in saving its state needs to be done for ALL game objects that need their state saved. Depending on how many objects you have and how complex your draw operations are this can be a bit of work.Replaying
I glossed over some details but here's an important one: every frame you're doing
frame++
on your frame counter, and at the start of every frame you're starting sayingreplay_data[frame] = {}
so that the table that is going to hold all stored data in this frame is initialized. When replaying all you have to do is sayframe = 1
and set somereplaying
bool to true, and then you'll be back at frame 1, reading the replay data you saved back then. This means that you can also go back and forwards in time at will, as the gif below shows.While replaying, pausing the replay means not increasing the frame counter. Jumping forwards in time means increasing the frame counter by an amount bigger than 1. Jumping backwards in time means decreasing the frame counter by an amount bigger than 1. Going to the end of the replay means setting the frame counter to the size of
replay_data
. Going to the start of the replay means setting the frame counter to1
or to the number of the frame where you started recording. All of these are relatively simple operations that let you have control over your playback.END
There are some drawbacks to this way of doing things, the main one being the size of a replay. Initially I built this because I wanted to be able to watch players play the game and be able to learn something from it. For instance, in this video, the player takes 4 minutes to figure out what exactly he's supposed to do in the game: (click to watch)
This is totally not a cool thing because I'd randomly guess that like 50% of people who play the game end up quitting before they figure out what they have to do. This is an easy mistake to fix but it's the kind of thing that's also easy to miss if you don't actively watch people playing your game, and a replay system helps with that.
Sadly, using this current technique (with no optimizations, although I've tried a few optimizations and they didn't help that much), about 10 seconds of gameplay results in a 10mb file. This is a prohibitive size if I want people to send files to me, so eventually I'll get around to implementing the other way where I only record inputs (and maybe write an article about it too) for this purpose. Overall though this is was a cool thing to build because now at least making gifs out of the game is easier, since I can just pause everything, go back, forwards, move the camera around, and so on.