Welcome to the first installment of my Coding series of articles where I break down step by step how to achieve certain effects or mechanics in video games. The first of which I'll cover is a simple one but with big impact: slow motion. The code examples used are language agnostic (or pseudo-code), however will resemble Godot's gdScript as that's my engine of choice. gdScript is heavily inspired by python so most programmers should grasp the ideas fine.
Slow motion is a fantastic way to add flourish to a game making the most impactful moments even more so. Whether it's for finishing blows (kill shots), getting a power up, or indicating a near-death moment there's many useful and creative ways to introduce this mechanic. Feel free to comment below what clever uses you would/have used this technique for!
THE BASICS
To begin we must first understand the concept of a game speed variable. By storing a global value that holds the game speed we can access this from anywhere in our project to modify each object in unison with the overall speed. This value by default is 1.0f (a floating-point number of value 1). When we manipulate this speed number to 0.5f our game when set up will be running at half-speed, and a value of 2.0f gives us double speed.
So how does an object move at the speed we want using this global value? The key is in the update/clock function of your game objects. This is the function called each tick/frame and almost always comes with a variable to indicate how much time has elapsed since the last call. If not available by default it is simple to calculate yourself. This parameter is generally called the delta value (delta is often used in mathematics to indicate a value difference). This is an important value for any game as it keeps our objects moving at the same speed regardless of the current framerate (frames per second).
If we for example had an object move right and we didn't use this delta value we'd get faster movement on faster machines and slower movement on older ones. This isn't good practice considering just how diverse in computing power each PC and gaming device is, and just how frequent framerates can change during gameplay. To compensate we use this delta as a multiplier for our speed, which leads to consistent movement across machines. It also removes a lot of the guess work when picking speed values as they are consistent and precise; For example to move right 100 pixels per second it simply becomes
Player.x += 100 * delta
With this setup making time move differently is as simple as multiplying our delta by our global speed value.
delta *= game_speed
Player.x += 100 * delta
You could just multiply on the same line as your change but just remember that any math done with the delta value in this function must be multiplied by the game speed first. Ex:
Player.x += 100 * delta * game_speed
Player.y += 100 * delta * game_speed
PARTICLE EMITTERS
Nice! We now have all our objects moving at the correct speed based on the game's speed. But wait, our particle effects are still moving in real-time! I will make the leap in assuming anyone reading this tutorial is making use of a game engine. Gone are the days where learning to make games requires making an 'engine' first. This makes our job much easier here, as most engines have a speed multiplier variable built-in to the particle systems. In Godot for example Particle nodes come with a parameter called speed_scale under the Time section of parameters in the inspector.
If you are indeed coding your own engine first then multiply the game speed by the particle lifetime much like modifying object speed in the previous example, this next part isn't applicable.
This speed scale acts exactly the same way our global game_speed value, so 0.5f is half speed and 2.0f is double. Now we just need to connect the two! There's a few ways this can be achieved and it may vary engine to engine too: for example you could make use of signals if your engine uses them to trigger a function whenever our global speed value is changed. Alternatively one could just update the particle emitter's speed_scale to match the global game speed in the update function for the emitter, ex:
func update(delta):
speed_scale = game_speed
As you can see here we don't need the delta value in this case, we're just using the main loop to update the particle emitter's own built-in speed scale.
SHADERS
The next challenge is now some of our shaders such as smoke or fog are still running in real-time, ignoring our game speed manipulation. Many shaders make use of using the built-in TIME parameter to move textures around to make interesting shapes and patterns. This is quite common and so any shader using the built-in TIME parameter is going to need altering too. Luckily it doesn't take much extra work; Instead of using the default time value let's bring in our own modified time value.
To understand what's going on one must first know that the built-in time value is a stopwatch of sorts, giving you the value of time in seconds just how long the game has been running. So a game running a minute would be 60.0f, two minutes 120.0, etc. We can create our own time tracker quite easily by adding a modified delta value each frame.
func update(delta):
time_elapsed += delta * game_speed
Here we can see that by simply adding up the delta value each tick we get an accurate representation of the elapsed time. By multiplying it with our game speed we are now tracking the time elapsed from the perspective of an object in the augmented time. So a second of half speed would only add half a second to the clock. To make our shaders work in slow-motion now all we need to do is swap the built-in TIME parameter in the shader code to our own modified version, being sure to update the shader's time value each frame as needed (otherwise time stands still in the shader)
func update(delta):
...
material.set_shader_param("time", time_elapsed)
MASKING
Awesome! We now have our game running in full slow (or fast) motion complete with particles, smoke, explosions, etc. Sometimes though it's a bit much slowing everything down, and leads to frustration in some when so much control of the player is momentarily lost or hampered. Why not masking? If we were to allow exceptions to which or how much objects were subject to these speed shifts we could do a lot more to control the experience.
One simple way would be to add another multiplier to your game objects, a value between 0 and 1 (a unit normal) that determines how subject it is to the game's speed.
local_speed = 1.0 - (1.0 - game_speed) * speed_mask
This may seem like a lot for those without a head for maths but this simply gives us the game speed but modified to only tweak the speed as much as the mask value allows. The default value for speed_mask would be 1.0f, and by moving the value closer to 0 it becomes less influenced by the game_speed. An object with a speed_mask of 0 is completely unaffected by the game_speed changes and a value of 1 is fully influenced by it.
This technique is useful for objects you want to remain in some or complete control of during slow motion, such as a player character, or an animation from picking up a power up. Also be mindful that if you want your local shaders to move at the locally modified (masked) speed you would need to change your time_elapsed value accordingly.
func update(delta):
local_speed = 1.0 - (1.0 - game_speed) * speed_mask
local_time_elapsed += delta * local_speed
material.set_shader_param("time", local_time_elapsed)
You can take this idea further and have different mask values based on whether the game is running faster or slower
if game_speed > 1.0:
local_speed = 1.0 - (1.0 - game_speed) * high_speed_mask
else:
local_speed = 1.0 - (1.0 - game_speed) * low_speed_mask
SELECTIVE MASKING
Masking is a concept that can be applied in a variety of ways here. Another one to consider is source masking. What if you have objects that are subject to speed shifts but ignores certain speed-changing events? Using a toggle, even a slider type such as our mask above doesn't work when there can be several sources of speed changes all with different overlapping timings. In this case we would need to store identification to game speed modifiers.
func set_game_speed(value, source = self):
game_speed_sources[source] = value
game_speed = 1.0
for _source_ in game_speed_sources:
game_speed *= game_speed_sources[_source_]
There are ways we can optimize this too, but this gives us an idea of what I mean. By attaching a source to the game_speed we can now inject masking game_speed by source. Right now we're just setting up the requirements for it in the global script, but before we tackle the actual masking let's modify our function further to be better optimized.
func set_game_speed(value, source = self):
var _in_sources_ = source in game_speed_sources.keys()
if _in_sources and value != game_speed_sources[source] or not _in_sources_:
if value == 1.0f and _in_sources_:
game_speed_sources.erase(source)
else:
game_speed_sources[source] = value
game_speed = 1.0
for _source_ in game_speed_sources.keys():
game_speed *= game_speed_sources[_source_]
So the two optimizations made here are first it checks whether the value has been modified or is new before running through the rest of the function. The next change is now it discards and erases sources that are set to the default speed of 1. If you have this function called often it can further be optimized by running through the for loop in the update function instead, thereby ensuring it's never cycled through more than once per tick.
Further changes can be made depending on the application of your speed changes, if for example you only want to do slow motion and never have a cause for speed-ups it would be better to pick the lowest value in the sources list instead of multiplying the values together. Similarly if you're only using this technique for speed-ups it makes more sense to pick the maximum value of the list.
game_speed *= game_speed_sources[_source_]
BECOMES
game_speed = min(game_speed, game_speed_sources[_source_]
Now that we got our modified game_speed setter we need an accompanying getter to make it work. This function is called instead of referencing the game_speed value itself if you want any masking, otherwise just using the game_speed value is fine.
func get_speed(source_masks = []):
if source_masks.size() > 0:
var _speed_ = 1.0
for _source_ in game_speed_sources.keys():
if not _source_ in source_masks:
_speed_ *= game_speed_sources[_source_]
return _speed_
return game_speed
And there you have it, by calling this function in your global script from your game object (be it player, projectile, etc) you can now filter out speed manipulations from desired sources. This could be strict object names, object group names, whichever works best for your project.
FURTHER IDEAS
Your moments of slow motion should ease naturally into your gameplay. Using linear easing looks jarring and should be avoided where possible unless intentionally doing so with reason. When slowing down or speeding up time try using different easing formulas, each have their own feel and works well for varying situations. A quick ease in that slows down is great for action moments, slow easing that speeds up is great for time warping powers. Feel free to experiment, perhaps speeding up time for a quick moment before entering slow-mo. Some advantages of using easing formulas are there's plenty to get you started without needing to know the math behind them, there's plenty of documentation, and most engines already include them. In Godot you can find them as Tween nodes.
FINAL THOUGHTS
There are many interesting ways this idea can be manipulated or refined upon further, and I'd be curious to see what others come up with. This guide is to get you started and thinking about the various edge cases that might pop up when building such a feature into your game. Some of these methods are overkill for some needs but by providing a breakdown of how I tackled the problem myself I hope to at least inspire more people to try speed modifier mechanics. They are quite fun, take less math than you think and looks cool every time.
CHALLENGE
With the basics now fully understood I lay down a challenge to you to think about. How would you take your time manipulation mechanic to the next level by adding REVERSE SPEED?
Good luck and have fun.
- Leo LeBlanc