This is part 4 of 4 in a series about some recent performance tuning I completed on Elevation TD.
Intro
Start with the very basic assertion anything that does not need to be done every frame should not be done every frame. There is no golden rule about what should or should not be done every frame – it depends highly on your specific game and what you are trying to achieve.
For example:
- In an FPS shooter, movements often need to be instantaneous (i.e. checked every frame)
- In a top-down strategy game, movements can be a big laggy (i.e. an enemy might only evaluate if it needs to go to a new target every second or two – it does not need to be checked every frame)
It’s important to remind yourself that if something only needs to happen once every 2 seconds instead of every frame, that is the difference between running some code 120 times (at 60 FPS) versus just once. Running it once will not only be less impactful on performance, but will also free up cycles to do a more complicated version of the process if desired. So if you have code that is checking for enemies each frame, checking to see if the player is in range each frame, seeking a new destination each frame, you might benefit a lot by using timers.
What is a timer?
Let me start off by saying that Godot has Timers you can add via Add Child Node and then link up with Signals. You can read about them here: https://docs.godotengine.org/en/stable/classes/class_timer.html. I don’t use them. I “roll my own” because I like to have a multitude of things I am running timers on and, frankly, its pretty easy to code.
Lets take a very simple use case of “regoaling” – its common for enemies in a tower defense to periodically check to see if they have a new target – they start out with a goal, new towers are built by the player, they have to change their goals to attack those new towers, more towers are built, they change goals again, etc… You don’t want them doing this constantly, maybe every 3 seconds or so – and you want it to be configurable. So put your regoaling logic in a function and call it from a timer like so:
# make the frequency of checking configurable from editor
@export var reassessGoalFreq:float = 3000
# give yourself something to track it with
var nextReGoalTime:float=0
func _process(delta):
# if your tracking number is in the past, run regoal
if nextReGoalTime < Time.get_ticks_msec(): regoal()
func regoal():
# set your next check time in the future by whatever
# reassessGoalFreq is set to
nextReGoalTime = Time.get_ticks_msec() + reassessGoalFreq
# ...
# do whatever complicated logic you need to do to check
# if this thing needs a new goal
# ...
IMHO, the above is way easier than adding nodes, linking signals, setting callbacks, etc… This is basically just two variable declarations and two lines of code. The check in _process is very light weight as you’re just comparing two floats. The way this plays out is:
- First iteration of _process, nextReGoalTime will always be less that get_ticks_msec (number of milliseconds since start of game), so it will fire off regoal() to set an initial goal
- Regoal() promptly resets nextReGoalTime to some point in the future that is current milliseconds since start of game plus whatever you have the reassessGoalFreq set to – its actually kind of important to do this first – if you have anything in regoal() that does an await get_timer() then _process might start doubling up calls to regoal() if nextReGoalTime isn’t in the future
- Regoal() finishes and life returns to _process() until your are reassessGoalFreq number of milliseconds in the future
Its worth noting that since nextReGoalTime and reassessGoalFreq are class-level floats, you can do things like adjust them as needed – for example, if you spawn a bunch of enemies at the same time and want to make sure they don’t regoal at the same time, you can modify reassessGoalFreq with a random float to make sure they are all operating at slightly different intervals.
In the case of Elevation TD, I have a whole set of these kinds of timers controlling enemy movements, resetting goals, checking if enemies should be attacking, checking if towers should be attacking, and so on. In my case, leveraging timers like in the above made a significant difference in FPS.
Conclusion
Using timers to control the frequency of events is a great example of the kind of “unsexy plumbing” in the code that can make a big difference in performance. The above implementation style is easy to wrap around a wide variety of situations. All you need to do to take advantage of it is identify processes that are currently running each frame that can be run much less often.
See the other parts of this series: