Better Time Management in SpriteKit

johansteen

Johan Steen

Posted on May 8, 2022

Better Time Management in SpriteKit

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
  }
}
Enter fullscreen mode Exit fullscreen mode

In the life cycle 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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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 life cycle, 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)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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 SKViewDelegate2 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
  }
}
Enter fullscreen mode Exit fullscreen mode

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...
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

By that, we are now able to write clean game code like this.

movement = speed * Time.deltaTime
Enter fullscreen mode Exit fullscreen mode

What a beauty!


  1. Unity API: Time. Static properties that provides time information in Unity. 

  2. SKViewDelegate. Take custom control over the view's render rate. 

💖 💪 🙅 🚩
johansteen
Johan Steen

Posted on May 8, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related