Various Timer Implementations in Unity

Posted by : on

Category : Unity

Introduction: Creating Timers in Unity

Creating a timer in Unity that integrates seamlessly with the game loop is one of the most common tasks developers encounter when building a game. Timers are essential for various scenarios, such as implementing cooldowns for abilities, managing enemy spawn mechanics, measuring the time a player spends in a level, and more.

When measuring time independently of the game loop, you can use one of the built-in timers that C# provides. For more information on these options, you can check out my post What are the differences in the timers in C# ?. However, this post focuses on creating timers that work in tandem with Unity’s game loop, similar to the timers often implemented using coroutines. While I won’t include an example of a coroutine-based timer (since this isn’t the most efficient use of coroutines), I will share three alternative timer implementations you can consider.

Timers Designed for Different Needs

Creating a timer for a game heavily depends on the specific requirements and design of your project. With countless approaches available, I’ve chosen to showcase three distinct types of timers to cover a variety of use cases:

1) Minimal Timer

The first timer is a simple, lightweight solution designed to determine whether a specific duration has elapsed. It lacks additional features but is highly performant, making it ideal for scenarios where minimizing overhead is crucial.

2) Update Timer

The second timer is a more versatile option, offering several helpful features:

  • Pause and unpause functionality.
  • Temporary pauses for a predetermined duration.
  • Reset capability and the ability to add time to the timer’s duration.
  • Support for repeating indefinitely or a set number of times.
  • Query options to retrieve useful information, such as time remaining, elapsed time, pause status, completion status, and repeat counts.
  • Event-based notifications, allowing methods to execute automatically upon completion.

This timer requires a Tick method to be called from the Update method, allowing it to track elapsed time each game loop. While it doesn’t generate garbage, it is slightly less performant than the minimal timer because it’s implemented as a class rather than a struct.

3) Timer using Awaitable

The third timer is the most convenient to use, combining all the features of the second timer with an added benefit: it doesn’t require a Tick method to be manually called in each update. Instead, it integrates with Unity’s game loop using Unity Awaitables. For more details, see my post Asynchronous Code In Unity Using Awaitable and AwaitableCompletionSource This timer operates in a “fire-and-forget” fashion and doesn’t depend on MonoBehaviour or the Update method. However, the use of Awaitables does generate some garbage, albeit a minimal amount. For most games, this has negligible performance impact. However, in performance-critical scenarios, the first or second timer may be more suitable depending on your needs.

Implementation Details

The three timers presented here are by no means the only ways to create a timer in Unity. I chose these examples because they demonstrate distinct approaches, allowing you to mix and match functionalities to suit your specific needs. In general, the more convenient and feature-rich a timer is, the greater the potential performance impact. Therefore, designing a timer tailored to your requirements is more important than searching for a single “best” solution.

Precision in Time Calculation

In all my timer implementations, the elapsed time is calculated by subtracting the start time from the current time. This approach differs from examples where deltaTime is either accumulated each update or subtracted from the target duration. My method avoids the precision errors that can occur when working with floating-point numbers. Repeatedly adding or subtracting small floating-point values can lead to cumulative errors, eventually causing significant deviations from the expected result.

Minimal Timer Optimization

For the minimal timer, I use a readonly struct, which makes it highly performant as a value type, avoiding garbage collection entirely. However, this timer still relies on an Update method to achieve precise tracking of when the timer completes within the game loop. If precise tracking isn’t necessary, for example, if you only need to check whether an ability cooldown has finished when the player attempts to use it, the Update method can be omitted.

Event Subscriptions in Advanced Timers

Both the second and third timers offer event subscriptions, allowing methods to execute automatically when the timer completes. I’ve designed these timers so that resetting or stopping the timer clears all its internal state except the event delegate. This means any methods subscribed to the timer will remain subscribed for its next usage. The responsibility for unsubscribing is left to the user, giving them greater control over event management.

Now, let’s dive into the implementation of the minimal timer.

The Minimal Timer

The minimal timer is very simple, just four lines of code:

public readonly struct MinimalTimer
{
   public static MinimalTimer Start(float duration) => new(duration);
   public bool IsCompleted => Time.time >= _triggerTime && _triggerTime != 0;
   
   private readonly float _triggerTime;
   private MinimalTimer(float duration) => _triggerTime = Time.time + duration;
}

We include a check for _triggerTime != 0 due to the way structs are initialized in C#. For more details, you can refer to my post How To Ensure Correct Struct Initialization In C#. Beyond that, the implementation is straightforward. To create a new timer, you call the MinimalTimer.Start() static method. You can then monitor the IsCompleted property to determine if the timer has finished. For example:

public class TestTimer : MonoBehaviour
{
   private MinimalTimer minimalTimer;
   private bool isTimerDone;

   private async void Start()
   {
      await Awaitable.WaitForSecondsAsync(2f);
      minimalTimer = MinimalTimer.Start(3f);
      Debug.Log("Minimal timer started!");
   }

   private void Update()
   {
     if (minimalTimer.IsCompleted && !isTimerDone)
     {
        Debug.Log("Minimal Timer completed!");
        isTimerDone = true;
     }
   }
}

We use a boolean flag, isTimerDone, to ensure that our Debug statement executes only once when the timer completes, rather than repeatedly afterward.

Timer Using Update

Our next timer is the one that needs an update method for its Tick method to be called each game loop. Here’s the full script:

public class UpdateTimer
{
   public float Duration { get; private set; }
   public int Repeats { get; private set; }
   public bool IsActive { get; private set; }
   public float ElapsedTime => TimeRunning();
   public float TimeRemaining => _triggerTime + _pausedDuration - Time.time;
   public bool IsCompleted { get; private set; } // True if the timer has completed at least once.
   public int CompletionsCount { get; private set; }
   public event Action CompletedEvent;

   private float _triggerTime;
   private float _pausedTime;
   private float _pausedDuration;
   private float _startTime;
   private bool _hasStarted;
   private bool _loop;
   private CancellationTokenSource cts;
   
   public void Start(float duration) => Start(duration, 1, false);
   public void Start(float duration, bool loop) => Start(duration, 0, true);
   public void Start(float duration, int repeats) => Start(duration, repeats, false);

   // Resets everything EXCEPT the listeners
   public void Stop()
   {
      Duration = default;
      Repeats = default;
      IsActive = default;
      IsCompleted = default;
      CompletionsCount = default;
      _triggerTime = default;
      _pausedTime = default;
      _pausedDuration = default;
      _startTime = default;
      _hasStarted = default;
      _loop = default;
      cts.Cancel();
   }

   public void Reset(float duration) => Reset(duration, 1, false);

   public void Reset(float duration, bool loop) => Reset(duration, 0, true);

   public void Reset(float duration, int repeats) => Reset(duration, repeats, false);
   
   public void Pause()
   {
      if (!IsActive) return;
      
      IsActive = false;
      _pausedTime = Time.time;
   }

   public void UnPause()
   {
      if (IsActive || !_hasStarted) return;
      
      IsActive = true;
      _pausedDuration += Time.time - _pausedTime;
   }
   
   public async void Pause(float duration)
   {
      Pause();
      try
      {
         await Awaitable.WaitForSecondsAsync(duration, cts.Token);
      }
      catch when (cts.IsCancellationRequested)
      { }
      catch (Exception ex)
      {
         Debug.LogError(ex.Message);
      }
      UnPause();
   }

   public void AddTime(float duration) => _triggerTime += duration;
   
   public void Tick()
   {
      if (IsDurationExpired() && IsActive)
      {
         IsCompleted = true;
         CompletionsCount++;
         OnCompleted();

         if (CompletionsCount < Repeats || _loop)
            AddTime(Duration);
         else
            IsActive = false;
      }
   }
   
   private void Start(float duration, int repeats, bool loop)
   {
      if (_hasStarted)
      {
         Debug.LogWarning("This timer has already started! Stop it before trying to Start it again.");
         return;
      }
      
      _loop = loop;
      Duration = duration;
      Repeats = repeats;
      Init();
   }
   
   private void Reset(float duration, int repeats, bool loop)
   {
      Stop();
      Start(duration, repeats, loop);
   }
   
   private void Init()
   {
      IsActive = true;
      _startTime = Time.time;
      _hasStarted = true;
      _triggerTime = _startTime + Duration;
      cts = new CancellationTokenSource();
      Tick();
   }
   
   private bool IsDurationExpired() => Time.time >= _triggerTime + _pausedDuration;
   private float TimeRunning() => _hasStarted ? Time.time - _startTime - _pausedDuration : 0;
   private void OnCompleted() => CompletedEvent?.Invoke();
}

The timer can be started using one of three available methods:

  • Providing only the timer’s duration as a parameter.
  • Adding a second parameter to specify either the number of repeats or a boolean indicating that the timer should repeat indefinitely.

Timer Methods and Properties

  • Stop Method: Stops and resets the timer while preserving any listeners subscribed to its CompletedEvent delegate.
  • Duration and Repeats Properties: Return the provided duration and the number of repeats, respectively.
  • IsActive Property: Returns true if the timer is active and not paused.
  • Pause Methods: The timer can be paused using Pause(), unpaused using Unpause(), or paused for a specific duration by providing a parameter to Pause(float duration).
  • ElapsedTime Property: Indicates how much time has passed since the timer started, excluding any time the timer was paused.
  • TimeRemaining Property: Shows how much time remains until the next completion. Note that for repeating timers, there may be multiple completion events.
  • IsCompleted Property: Returns true if the timer has completed at least once.
  • CompletionsCount Property: Indicates the total number of times the timer has completed.
  • Reset Method: Resets the timer, equivalent to stopping it and starting it again.
  • AddTime Method: Adds additional time to the duration of a timer that has already started.
  • CompletedEvent: Invoked every time the timer completes.
  • Tick Method: Responsible for checking elapsed time and triggering the appropriate actions, including invoking the CompletedEvent and handling repeat behavior. It is crucial to call this method every Update to ensure the timer functions correctly.

Here’s an example of how to use this timer:

public class TestTimer : MonoBehaviour
{
   private readonly UpdateTimer timer = new();

   private void OnEnable() => timer.CompletedEvent += ShowMessage;

   private async void Start()
   {
      timer.Start(3f, 2);
      await Awaitable.WaitForSecondsAsync(1f);
      Debug.Log($"Time remaining: {timer.TimeRemaining}");
      Debug.Log($"Timer running: {timer.ElapsedTime}");
      await Awaitable.WaitForSecondsAsync(3f);
      Debug.Log($"Time remaining: {timer.TimeRemaining}");
      Debug.Log($"Timer running: {timer.ElapsedTime}");
      timer.Start(1f, 5);
      timer.Stop();
      timer.Start(2f, 3);
      timer.Reset(4);
      timer.Pause(3f);
      await Awaitable.WaitForSecondsAsync(1f);
      timer.Reset(2f,2);
   }

  private void Update() => timer.Tick();

  private void OnDisable() => timer.CompletedEvent -= ShowMessage;

  private void ShowMessage() => Debug.Log($"<color=yellow>Time elapsed in timer: {timer.ElapsedTime}</color>");
}

Running the above and looking at the console’s time stamps shows different behaviors of the UpdateTimer

Timer Using Awaitable

Lastly, we have the timer that utilizes Unity’s Awaitable. While the nature of Awaitable generates some garbage, this timer offers all the functionalities of the previous timer but without requiring a Tick method to be called in Update. Its integration with Unity’s game loop is achieved through Awaitable.NextFrameAsync.

The implementation is slightly more complex because it must handle the cancellation of the Awaitable in case the user wants to stop the timer. Despite this, the timer is highly convenient to use. Since it doesn’t depend on an Update method for its operation, it can be used in both MonoBehaviour scripts and Plain Old C# Objects (POCO) classes.

Aside from these differences, the functionality is identical to the previous timer. Here’s the same example applied to this timer:

public class TestTimer : MonoBehaviour
{
   private readonly Timer timer = new();

   private void OnEnable() => timer.CompletedEvent += ShowMessage;

   private async void Start()
   {
      timer.Start(3f, 2);
      await Awaitable.WaitForSecondsAsync(1f);
      Debug.Log($"Time remaining: {timer.TimeRemaining}");
      Debug.Log($"Timer running: {timer.ElapsedTime}");
      await Awaitable.WaitForSecondsAsync(3f);
      Debug.Log($"Time remaining: {timer.TimeRemaining}");
      Debug.Log($"Timer running: {timer.ElapsedTime}");
      timer.Start(1f, 5);
      timer.Stop();
      timer.Start(2f, 3);
      timer.Reset(4);
      timer.Pause(3f);
      await Awaitable.WaitForSecondsAsync(1f);
      timer.Reset(2f,2);
   }

  private void OnDisable() => timer.CompletedEvent -= ShowMessage;

  private void ShowMessage() => Debug.Log($"<color=yellow>Time elapsed in timer: {timer.ElapsedTime}</color>");
}

No Update method is required, which means it can be used in any regular method within any class, not just Unity’s Start event method.

Here’s the code:

public class Timer
{
   public float Duration { get; private set; }
   public int Repeats { get; private set; }
   public bool IsActive { get; private set; }
   public float ElapsedTime => TimeRunning();
   public float TimeRemaining => _triggerTime + _pausedDuration - Time.time;
   public bool IsCompleted { get; private set; }  // True if the timer has completed at least once.
   public int CompletionsCount { get; private set; }
   public event Action CompletedEvent;

   private float _triggerTime;
   private float _pausedTime;
   private float _pausedDuration;
   private float _startTime;
   private bool _hasStarted;
   private bool _loop;
   private CancellationTokenSource cts;
   
   public void Start(float duration) => Start(duration, 1, false);
   public void Start(float duration, bool loop) => Start(duration, 0, true);
   public void Start(float duration, int repeats) => Start(duration, repeats, false);

   // Resets everything EXCEPT the listeners
   public void Stop()
   {
      Duration = default;
      Repeats = default;
      IsActive = default;
      IsCompleted = default;
      CompletionsCount = default;
      _triggerTime = default;
      _pausedTime = default;
      _pausedDuration = default;
      _startTime = default;
      _hasStarted = default;
      _loop = default;
      cts.Cancel();
   }

   public void Reset(float duration) => Reset(duration, 1, false);
   public void Reset(float duration, bool loop) => Reset(duration, 0, true);
   public void Reset(float duration, int repeats) => Reset(duration, repeats, false);
   
   public void Pause()
   {
      if (!IsActive) return;
      
      IsActive = false;
      _pausedTime = Time.time;
   }

   public void UnPause()
   {
      if (IsActive || !_hasStarted) return;
      
      IsActive = true;
      _pausedDuration += Time.time - _pausedTime;
   }
   
   public async void Pause(float duration)
   {
      Pause();
      try
      {
         await Awaitable.WaitForSecondsAsync(duration, cts.Token);
      }
      catch when (cts.IsCancellationRequested)
      { }
      catch (Exception ex)
      {
         Debug.LogError(ex.Message);
      }
      UnPause();
   }

   public void AddTime(float duration)
   {
      if (!_hasStarted) return;
      
      _triggerTime += duration;
   }
   
   private void Start(float duration, int repeats, bool loop)
   {
      if (_hasStarted)
      {
         Debug.LogWarning("This timer has already started! Stop it before trying to Start it again.");
         return;
      }
      
      _loop = loop;
      Duration = duration;
      Repeats = repeats;
      Init();
   }
   
   private void Reset(float duration, int repeats, bool loop)
   {
      Stop();
      Start(duration, repeats, loop);
   }
   
   private void Init()
   {
      IsActive = true;
      _startTime = Time.time;
      _hasStarted = true;
      _triggerTime = _startTime + Duration;
      cts = new CancellationTokenSource();
      Tick();
   }
   
   private async void Tick()
   {
      do
      {
         await WaitForCompletion();

         if (cts.IsCancellationRequested)
            break;
         
         IsCompleted = true;
         CompletionsCount++;
         OnCompleted();

         if (CompletionsCount < Repeats || _loop)
            AddTime(Duration);
         
      } while (!IsDurationExpired());
      
      IsActive = false;
   }
   
   private async Awaitable WaitForCompletion()
   {
      while (HasToWait())
      {
         try
         {
            await Awaitable.NextFrameAsync(cts.Token);
         }
         catch when (cts.IsCancellationRequested)
         { }
         catch (Exception ex)
         {
            Debug.LogError(ex.Message);
         }
      }
   }

   private bool HasToWait() => (!IsActive || !IsDurationExpired()) && !cts.IsCancellationRequested;
   private bool IsDurationExpired() => Time.time >= _triggerTime + _pausedDuration;
   private float TimeRunning() => _hasStarted ? Time.time - _startTime - _pausedDuration : 0;
   private void OnCompleted() => CompletedEvent?.Invoke();
}

The only difference compared to before is in the Tick method. It is now private and includes a loop that waits for the next frame, automatically checking if the timer’s duration has expired.

Conclusion

I hope you find these three timer implementations helpful. Feel free to use them as-is or, even better, as a foundation to create custom timers tailored to your game’s specific requirements.

  • The first timer is the most performant.
  • The third timer is the most convenient to use and offers the most features.
  • The second timer strikes a balance between performance and ease of use.

You can find the code for all three timers in this post, on my GitHub repository: Unity Timers

As always, thank you for reading, and if you have any questions or comments you can use the comments section, or contact me directly via the contact form or by email. Also, if you don’t want to miss any of the new blog posts, you can subscribe to my newsletter or the RSS feed.


Follow me: