This post is about implementing custom timelines in GameMaker.
Admittedly, timelines remain to be one of the more dated features of the software, designed for a specific purpose and kept in their original form for compatibility.
Time to time you'll see someone stopping by on forums to write a big rant about how these are a mess, do not do what they expect them to do, and should be redesigned immediately.
People will usually also argue that doing these things by yourself is too hard.
Since it is far easier than people claim, I decided to write a post about this.
The idea
A naive implementation of custom timelines is pretty trivial - you have a variable denoting the current time, increase it every frame, and perform actions if it's equal to one or other value.
In many cases that is sufficient, but there's a catch: if you do not advance a steady rate of 1 timeline frame per game frame, you might accidentally skip over frames.
While this might seem like a big scary math problem, this is one of the things that really aren't - the trick is that instead of checking if a value is equal to a moment's time, you check that the value rolled over (was previously less or equal but now is greater than) over the moment's time.
The code
Here, ctl is an arbitrary picked prefix standing for custom timeline;
First you'll need to add a script called ctl_moment, which would do
/// ctl_moment(time_mark) return ctl_time_previous <= argument0 && ctl_time > argument0;
to check that ctl_time "rolled over" the given point this frame.
Then, you would add another script called ctl_update, which would do
/// ctl_update(time_delta) ctl_time_previous = ctl_time; ctl_time += argument0;
to advance ctl_time and set ctl_time_previous.
Using
Using this system is just as simple. First, you would set up the ctl_time variable in Create event:
ctl_time = 0;
Then, in Step event (or End step, depending on preferred order) you would call ctl_update and process your moments,
ctl_update(1); // as in, 1 timeline frame per game frame // moment handling: if (ctl_moment(1)) { show_debug_message("Frame 1!"); } if (ctl_moment(2)) { show_debug_message("Frame 2!"); } if (ctl_moment(10)) { show_debug_message("Frame 10!"); }
Possible upgrades
And now for a few things that you might want on top of this:
Going backwards
If you want to be able to have the timeline go backwards, you would need to modify ctl_moment to check time offsets the other way around in that case:
/// ctl_moment(time_mark) if (ctl_time >= ctl_time_previous) { return ctl_time_previous <= argument0 && ctl_time > argument0; } else return ctl_time_previous >= argument0 && ctl_time < argument0;
Multiple timelines
There are multiple approaches that can be used to make such a system work with multiple timelines at once - you could
- Make ctl_time an array, add a ctl_index variable to mark currently handled timeline, have ctl_update take an index argument and set ctl_index, and have ctl_moment peek an array item based on ctl_index.
- Have a "init" script return an array with timeline's data and other scripts take such an array as an argument.
- Just make a second set of scripts with different names and affected variables (if you have no idea how to do other options).
Changing timelines
If you require changing timelines on the fly, that can be done by simply introducing logic to run one or other set of ctl_moment calls - either via direct conditions or by storing a script index and running it via script_execute.
Dynamic timelines
Having dynamic timelines isn't really in "simplest possible" category, but not too bad either, as you can just stuff information about moments into a list and then loop over it, applying the same logic.
So you would have a script that initializes things (for Create event):
/// dtl_init(start_time) dtl_time = argument0; dtl_moments = ds_list_create();
and a script that cleans things up (for Destroy event in GMS1 or Cleanup in GMS2):
/// dtl_cleanup() ds_list_destroy(dtl_moments); dtl_moments = -1;
and a script that adds moment data into that list:
/// dtl_moment_add(time, script, ...arguments) if (argument_count < 2) show_error("Not enough arguments", 0); var m = array_create(argument_count); for (var i = 0; i < argument_count; i++) { m[i] = argument[i]; } ds_list_add(dtl_moments, m);
and an update script which would loop over moments and execute the next one(s):
/// dtl_update(time_delta) dtl_time_previous = dtl_time; dtl_time += argument0; var dtl_size = ds_list_size(dtl_moments); for (var i = 0; i < dtl_size; i++) { var m = dtl_moments[|i]; var t = m[0]; if (dtl_time_previous <= t && dtl_time > t) { switch (array_length_1d(m)) { case 2: script_execute(m[1]); break; case 3: script_execute(m[1], m[2]); break; case 4: script_execute(m[1], m[2], m[3]); break; case 5: script_execute(m[1], m[2], m[3], m[4]); break; case 6: script_execute(m[1], m[2], m[3], m[4], m[5]); break; default: show_error("Add more cases to dtl_update", 0); } } }
Then you could set up your moments in Create event,
dtl_init(0); dtl_moment_add(10, scr_trace, "10"); dtl_moment_add(20, scr_trace, "20");
(where scr_trace is a script that does show_debug_message(argument0))
and trigger update in Step event:
dtl_update(1);
If you want to mix custom and hardcoded moments, you could add a dtl_moment script with code much akin to earlier shown ctl_moment:
/// dtl_moment(time_mark) return dtl_time_previous <= argument0 && dtl_time > argument0;
and have branches of these in Step event after dtl_update call.
If you have a lot of custom moments, the algorithm can be made more optimal by introducing a separate variable for position in the list, keeping the list sorted, and advancing through the list while the item at current position matches the condition.
In conclusion
As can be seen, pretty much the entirety of timeline functionality can be accurately replicated with custom code, much like the commonly requested features related to these.
Have fun!
I want to make a great game
Me too, to be honest
Thank you for this excellent script. Just one question though. This part here:
ctl_update(1); // as in, 1 timeline frame per game frame
// moment handling:
if (ctl_moment(1)) {
show_debug_message(“Frame 1!”);
}
if (ctl_moment(2)) {
show_debug_message(“Frame 2!”);
}
if (ctl_moment(10)) {
show_debug_message(“Frame 10!”);
}
Should it not be “else if” for showing those debug messages instead of just using “if”?
Overhead of checking a pair of variables is pretty minimal, but you may use else-if and/or gml_pragma(“forceinline”) if performance is an extreme concern.
Nice! Very clean approach, as always.