Better Time Management in SpriteKit
Setup a ticker with Swift and SpriteKit to have relevant relevant time information like delta time available anywhere in your game code.
Time management and the usage of delta time is one of the most important and fundamental parts for most game projects as it ensures consistency of any on-screen transforms no matter what the device's frame rate is that the game runs on.
More or less any form of movement, rotation or time-related calculation should take delta time into account to ensure it's not dependent on the frame rate.
Without using delta time, a bullet in a game, as an example, would move at a different speed on a 120 fps device compared to a 60 fps device.
Delta Time in SpriteKit
SpriteKit does not give us a delta time value out of the box, which is unfortunate considering how useful it is.
It's not that the game framework engineers at Apple are not aware of delta time,
as the GKComponent
in GameplayKit requires delta time in its update(deltaTime:)
method. But you have to calculate the value yourself.
At the same time as it's unfortunate that SpriteKit does not provide delta time
out of the box, it's also understandable that the GameplayKit engineers deemed
deltaTime
as a requirement when calling the update method, as a component will most
likely need to use delta time in one way or another.
And of course, if not using GameplayKit but only plain SpriteKit, you are going to need to use a delta time for your on-screen calculations to ensure consistency no matter what frame rate the device runs on.
SKScene provides the current time value, so most likely you are using or have seen solutions similar to this to get the delta time value.
class GameplayScene: SKScene {
/// Keeps track of how much time has passed since last game loop update.
var lastUpdateTime: TimeInterval = 0
override func update(_ currentTime: TimeInterval) {
super.update(currentTime)
// Get delta time since last time `update` was called.
let deltaTime = calculateDeltaTime(from: currentTime)
}
/// Calculates time passed from current time since last update time.
private func calculateDeltaTime(from currentTime: TimeInterval) -> TimeInterval {
// When the level is started or after the game has been paused, the last update time is reset to the current time.
if lastUpdateTime.isZero {
lastUpdateTime = currentTime
}
// Calculate delta time since `update` was last called.
let deltaTime = currentTime - lastUpdateTime
// Use current time as the last update time on next game loop update.
lastUpdateTime = currentTime
return deltaTime
}
}
In the lifecycle of this SKScene
we get the current time provided in the update
method. By storing the current time value in the class property lastUpdateTime
,
we are able to keep track of how much time that has elapsed since the last time
the update method was called, and by that we have our delta time value.
We can then take this value and keep passing it along to other methods or systems that need to use delta time for their calculations.
Which could look something like this.
override func update(_ currentTime: TimeInterval) {
super.update(currentTime)
let deltaTime = calculateDeltaTime(from: currentTime)
// Update the bullet system that handle movement of all bullets on screen.
bulletSystem.update(deltaTime: deltaTime)
// Update each GameplayKit component system.
for componentSystem in componentSystems {
componentSystem.update(deltaTime: deltaTime)
}
}
Each system, component or object that uses delta time will then have to accept the value in its method signature, and then pass it along further down the chain to its own methods that relies on delta time. You most likely will not want to keep all your logic in the update method, as it could grow massive, but structure the logic in dedicated methods.
We then end up with components like this.
class MoveComponent: GKComponent {
// ... Initialization and references to other components of the entity here ...
override func update(deltaTime seconds: TimeInterval) {
super.update(deltaTime: seconds)
move(deltaTime: seconds)
}
private funct move(deltaTime seconds: TimeInterval) {
entity.position += speed * direction * CGFloat(seconds)
}
}
Here we have a move method in the component that moves the entity's position at
a speed and direction that is not dependent on the device frame rate as we multiply
the resulting velocity with our delta time value. As delta time is passed as a TimeInterval
we have to cast it to
an appropriate format, like CGFloat
.
Most games will of course not only use the update method
in SKScene, but will need to use the other methods in the
lifecycle, such as didSimulatePhysics
or didFinishUpdate
.
Those methods do not get the current time injected, so we are going to have to
promote the deltaTime
property to a class property to be able to access and use
delta time in those scenarios.
class GameplayScene: SKScene {
var deltaTime: TimeInterval = 0
override func update(_ currentTime: TimeInterval) {
deltaTime = calculateDeltaTime(from: currentTime)
}
override func didSimulatePhysics() {
// Update camera movemement.
camera?.entity?.update(deltaTime: deltaTime)
// Update each 'after physics' component system.
for componentSystem in afterPhysicsComponentSystems {
componentSystem.update(deltaTime: deltaTime)
}
}
}
While this is an approach that does the job, and SpriteKit provides us with the necessary data to deal with delta time, it's quite convoluted.
We are mixing the time logic with the scene logic. And as every calculation that affects what happens on the screen, which is a lot in most games, relies on delta time, we are going to have to keep passing the value around between systems, classes and methods.
We might also have multiple scenes, even running at the same time, which happens when transitioning between scenes, which would create multiple delta time calculations at the same time.
The more complex a game becomes, with more systems and classes, the more this approach will feel to start falling apart. Let's find a better way.
Game Engines
Looking at modern game engines such as Unity and Unreal, they calculate delta time as a core functionality and the value is made available to use where you need it. It makes sense that any framework that targets game development makes this available.
If we for instance take a closer peek at Unity, it provides a Time class1 that has static properties containing time-related data.
By that, you never have to pass any time information around in your game code. Instead,
you can at any time do a calculation like this, by just statically accessing the
deltaTime
property of Time
.
var position += speed * direction * Time.deltaTime;
It's apparent that they decided that something so crucial and that is used so often in game development should be calculated under the hood and be easily available at any time, which making it static solves.
The time class in Unity does not only provide deltaTime
as a static property but
has a ton of different useful time-related information.
Swift Class for Static Time
We have now examined the ordinary approach to dealing with delta time in SpriteKit as well as taken a peek at how modern game engines handle it. So let's aim for a similar approach in SpriteKit for a better way to easily get access to time information via static properties.
We'll take inspiration from Unity and see how we can have a Time
class available in SpriteKit with static
properties that are not tied to the current scene's update loop.
The static class will need to hold relevant properties together with an
update()
method to keep the values current.
/// Tracks the game's time-related information.
public enum Time {
/// The time given at the beginning of this frame.
public private(set) static var time = TimeInterval(0.0)
/// The interval in seconds from the last frame to the current one.
public private(set) static var deltaTime = TimeInterval(0.0)
/// Called on the device's frame update to track time properties.
static func update(at currentTime: TimeInterval) {
// If `time` is 0.0 the game has just started, so set it to `currentTime`.
if time.isZero {
time = currentTime
}
// Calculate delta time since `update(at:)` was last called.
deltaTime = currentTime - time
// Set `time` to `currentTime for next game loop update.
time = currentTime
}
}
That gives us the Time
class with the static properties and a method to keep them
up to date. Now we just need a place to call the update()
method. The first thought
might be to call it from update()
in SKScene
. Which would work most of the time.
We do get one problem if we do it from SKScene
, at some times we
might have more than one scene running, for instance when transitioning between
two scenes. That would cause a race condition when then they would both update Time
, which will give us an incorrect deltaTime
for one of the scenes.
We also want to keep our code clean and not have to remember to call
Time.update()
in every scene class we create, as well as we want to avoid polluting
the scene class with code that doesn't have to be there.
Luckily SKView
has a delegate property that we can use for this. The delegate property takes an SKViewDelegate
2 object, which gives us the hook we
need to have a place to calculate time values outside the scene class.
Let's create our own Ticker
class, which is an SKViewDelegate
so we can update
our Time
class. The view()
method in SKViewDelegate
is called just before the
update()
method in SKScene
, so it's a perfect place for us to use to update
time properties.
/// Assign to the `SKView` to update game time properties for each frame.
public class Ticker: NSObject, SKViewDelegate {
public func view(_ view: SKView, shouldRenderAtTime time: TimeInterval) -> Bool {
// Update time properties.
Time.update(at: time)
// By returning true the game runs at the full frame rate specified with preferredFramesPerSecond.
return true
}
}
And finally, we need to assign the delegate to the SKView, during the game's initialization process.
I prefer to have a GameManager
class where I do all my early initialization.
I instantiate my GameManager
immediately from my ViewController
where I pass in the SKView
that will present my different game scenes.
That is the perfect spot to assign the custom delegate that will ensure that the time properties stay up to date without having to manage that on a per-scene basis.
class GameManager {
/// Ticker that updates game time properties.
let ticker = Ticker()
init(view: SKView) {
// Assign the game time ticker to the view.
view.delegate = ticker
// ... Other game setup code would go here...
}
}
And there we have it, a rock-solid reusable solution that gives us easy access to time properties like delta time from anywhere in our game code.
Whenever we need to do a delta time based calculation, we can now at any time
use Time.deltaTime
as part of the calculation.
Further Improvements
With a central location for our time properties, it doesn't have to
end with delta time. There are plenty of time-related properties that are
useful for game development. If we look at Unity's Time
class as a reference,
we could replicate more of the properties there and keep decorating our own
Time
class.
Let's say we also want to have access to the frame count in our game code.
public enum Time {
/// The total number of frames since the start of the game.
public private(set) static var frameCount = 0
static func update(at currentTime: TimeInterval) {
// ... previous code in the update method here ...
// Increase frame count since game started.
frameCount += 1
}
}
We can now at any time access Time.frameCount
if we need to read this value.
The final tweak, let's extend our value types operators to handle TimeInterval
.
Previously in this post we multiplied speed with delta time and had to cast
the time value to CGFloat, CGFloat(seconds)
. As we are going to use the delta
time value in many places, it would be convenient if we didn't have to cast
it every time. Instead, we can extend types we are going to use with delta time,
such as CGFloat
and CGVector
to use TimeInterval
.
Here's an example of extending CGFloat
so we can multiply a CGFloat
value directly
with a TimeInterval
without having to cast it every time.
public extension CGFloat {
static func * (lhs: CGFloat, rhs: TimeInterval) -> CGFloat {
return lhs * CGFloat(rhs)
}
}
By that, we are now able to write clean game code like this.
movement = speed * Time.deltaTime
What a beauty!
References
-
Unity API: Time. Static properties that provides time information in Unity. ↩
-
SKViewDelegate. Take custom control over the view's render rate. ↩