Countdowns are everywhere in games: time limits, bomb timers, round clocks, speedrun stopwatches, “next wave in 10”. They’re easy to write badly, with timers that drift, keep running while paused, or flicker text every frame. This guide builds a Unity countdown timer and stopwatch that formats as mm:ss, pauses properly, fires events at zero, and can run in unscaled time for menus.
A Reusable Timer Class
A plain C# class keeps the logic in one place, so gameplay, UI and tests can share it:
using System;
[Serializable]
public class Timer
{
public float Duration;
public float Remaining { get; private set; }
public bool Running { get; private set; }
public float Elapsed => Duration - Remaining;
public float Progress01 => Duration <= 0 ? 1f : 1f - Remaining / Duration;
public event Action Finished;
public Timer(float duration) { Duration = duration; Remaining = duration; }
public void Start() { Remaining = Duration; Running = true; }
public void Pause() => Running = false;
public void Resume() => Running = Remaining > 0f;
public void Add(float seconds) => Remaining = Math.Max(0f, Remaining + seconds);
public void Tick(float deltaTime)
{
if (!Running) return;
Remaining -= deltaTime;
if (Remaining <= 0f)
{
Remaining = 0f;
Running = false;
Finished?.Invoke();
}
}
}
The class doesn’t call Time.deltaTime itself; whoever owns it passes the delta in. That lets you choose scaled or unscaled time, and makes the timer easy to unit test by passing fixed values. The unit testing guide shows how to test classes like this.
A Countdown Component With Events
using UnityEngine;
using UnityEngine.Events;
public class Countdown : MonoBehaviour
{
[SerializeField] float seconds = 60f;
[SerializeField] bool startOnAwake = true;
[SerializeField] bool useUnscaledTime;
public UnityEvent onFinished;
public Timer Timer { get; private set; }
void Awake()
{
Timer = new Timer(seconds);
Timer.Finished += onFinished.Invoke;
if (startOnAwake) Timer.Start();
}
void Update()
{
Timer.Tick(useUnscaledTime ? Time.unscaledDeltaTime : Time.deltaTime);
}
}
Hook onFinished up in the Inspector to end the round, explode the bomb or open a door. Because the event fires once at the moment the timer crosses zero, you don’t need an “already finished” flag in every listener.
Why subtract deltaTime instead of comparing against Time.time? Both work. A subtracting timer makes pausing, adding bonus time and slowing time per-timer trivial. A “finish at Time.time X” timer is cheaper but harder to pause. For ability cooldowns the timestamp approach fits better, as in the cooldown UI guide.
Formatting mm:ss and Milliseconds
Players expect “1:05”, not “65.2341”. Convert seconds to minutes and seconds:
public static class TimeFormat
{
// 65.3 -> "1:05"
public static (int m, int s) MinSec(float t)
{
int total = Mathf.CeilToInt(t); // countdowns show 1 until it really hits 0
return (total / 60, total % 60);
}
// 65.347 -> "1:05.34"
public static (int m, int s, int cs) MinSecCenti(float t)
{
int total = Mathf.FloorToInt(t * 100f);
return (total / 6000, (total / 100) % 60, total % 100);
}
}
(Add using UnityEngine; for Mathf.) Note the rounding: countdowns should round up (CeilToInt), so the display shows “0:01” until the time actually runs out. Otherwise “0:00” appears for almost a full second before the event fires. Stopwatches should round down, so you never display more time than has passed.
Showing It in the UI Without Garbage
Building a string every frame with $"{m}:{s:00}" creates garbage. With TextMeshPro, use SetText with format placeholders, and only update when the shown value changes:
using TMPro;
using UnityEngine;
public class CountdownLabel : MonoBehaviour
{
[SerializeField] Countdown countdown;
[SerializeField] TMP_Text label;
int lastShown = -1;
void Update()
{
int total = Mathf.CeilToInt(countdown.Timer.Remaining);
if (total == lastShown) return;
lastShown = total;
label.SetText("{0}:{1:00}", total / 60, total % 60);
}
}
TMP’s SetText takes up to a few numeric arguments and formats them without allocating. {1:00} pads seconds to two digits. Updating only once per second means the text mesh is rebuilt once per second instead of every frame. The garbage collection guide explains why this matters on mobile.
Use a monospaced font or TMP’s <mspace> tag for timers, so digits don’t jiggle left and right as they change width.
A Stopwatch and Best Times
A stopwatch counts up instead:
using UnityEngine;
public class Stopwatch : MonoBehaviour
{
public float Elapsed { get; private set; }
public bool Running { get; private set; }
public void Begin() { Elapsed = 0f; Running = true; }
public void Stop() => Running = false;
void Update()
{
if (Running) Elapsed += Time.deltaTime;
}
public void SaveIfBest(string key)
{
float best = PlayerPrefs.GetFloat(key, float.MaxValue);
if (Elapsed < best) PlayerPrefs.SetFloat(key, Elapsed);
}
}
Show centiseconds with label.SetText("{0}:{1:00}.{2:00}", m, s, cs). Updating centiseconds means updating every frame, which is fine for one label.
For speedrun-accurate times, accumulating deltaTime is accurate enough for games. Just make sure the timer starts and stops on the same events every run.
Pausing and Unscaled Time
- Gameplay timers should use
Time.deltaTime. When your pause menu setsTime.timeScale = 0, deltaTime becomes 0 and the timer stops automatically. See the pause menu guide. - Menu and UI timers (a “Continue?” countdown on the game over screen, a menu animation) should use
Time.unscaledDeltaTime, so they keep running while gameplay is paused. - Slow motion affects scaled timers too. If a time limit shouldn’t slow down during bullet time, use unscaled time for it.
- Coroutines:
WaitForSecondsuses scaled time;WaitForSecondsRealtimeuses unscaled time.
If the app is backgrounded on mobile, Update stops. Scaled and unscaled delta both skip that time, which is usually what you want.
Polish: Warnings and Bonus Time
- Low time warning. Turn the label red and pulse it below 10 seconds. Play a tick sound once per second from the same “value changed” check.
- Bonus time. Call
Timer.Add(5)and pop a “+5” next to the clock. - Countdown to start. A 3-2-1-GO before a race is a 3 second timer with a big label and a sound on each change, using unscaled time if the game is paused during it.
🔧 UI and feedback tools
Juicy timers and HUDs are easier with good tween and UI tools. See our favourite Asset Store tools.
Troubleshooting
Timer shows 0:00 for a second before ending
You’re rounding down. Use CeilToInt for countdown display.
Timer keeps running while paused
It uses unscaled time, or your pause doesn’t set timeScale. Use Time.deltaTime for gameplay timers.
Timer stops during the pause menu countdown
The menu timer uses scaled time. Switch it to unscaledDeltaTime or WaitForSecondsRealtime.
Finished event fires twice
Two components subscribed, or you subscribed in OnEnable without unsubscribing. Subscribe once.
Digits jump around
Proportional font. Use a monospaced font or the mspace rich text tag.
FAQ
Coroutine or Update for timers?
Both work. Update with a Timer class is easier to pause, add time to and display.
How accurate is Time.deltaTime?
Accurate enough for gameplay. Over long sessions, accumulated float error is tiny compared with a frame.
Can I use System.Diagnostics.Stopwatch?
For measuring code performance, yes. For gameplay, use Unity time so pausing and timeScale work.
How do I show hours?
Divide total seconds by 3600 for hours, then use the remainder for minutes and seconds.
How do I save remaining time?
Store Remaining in your save data and set it back on load before resuming.
Reader questions
Asked by readers of this article and answered by AI. Behavior can differ between Unity versions, so test in yours.




Leave a Reply