The Smoke & Mirrors of Good Countdowns, Part 2

Last time, we looked at countdowns in games, how they are set up, and what elements you can use to make them more engaging. There are much more than will fit in a single article, though!

The Smoke & Mirrors of Good Countdowns, Part 2

The Smoke & Mirrors of Good Countdowns, Part 2

This will continue from Part 1, which is why we will start at number seven.

Ready? Let’s go!

7: Have a Different Constant Ticking Speed

This is a simple trick that does not even necessitate you lying to the player. You just say “when the timer runs out” and show a ticking clock. What you do not mention is the unit that is shown.

If you show a “10”, the player will intuit this is 10 seconds, but the number can decrease slower than seconds would. If you modify the value with a 0.5 multiplier, for example, it will run out after 20 seconds instead of just 10.

You can look at how this works in my Ludum-Dare game, Every Ten Seconds a Kitten Drowns.

The Smoke & Mirrors of Good Countdowns, Part 2

The theme of the jam necessitated that 10 seconds are used, but 10 actual seconds are a much-too-low amount of time to accomplish something meaningful.

8: Adapt the Ticking Speed During Gameplay

This also works better if time units are not mentioned and the player is just given a rough idea of “until this happens”.

If you have a series of panels that light up to show timer progress, you do not need to activate them at the same rate. In fact, it will become more intense if the first few light up quickly, and the latter ones have more time between them. In the heat of action-packed gameplay, the player will not realize this and will have a much more intense experience.

This should not be employed with actual time units, as players might feel cheated and lied to. Do not break the player’s trust in your system.

This can be seen in one level of Starfox 64, where the player has to defend a position against an approaching giant ship.

The Smoke & Mirrors of Good Countdowns, Part 2

On the first glance, you get a rough idea of how much time is left, but the ship itself appears to move at different speeds and not in a straight line.

The timer is in essence being adapted on the fly, the process of which is hidden behind smoke and mirrors.

9: Use Sounds

Countdowns do not have to be purely optical! A beep sound every few seconds will greatly enhance immersion.

Once you have that, you can also adapt the frequency once a certain amount of time has passed. If the beep occurs every five seconds, in the last 30 seconds of the timer the player will be notified without having to look at the number and mentally calculate how much is left.

Similarly,  it also helps if a character comments on the progress. Having someone say “We are halfway done!” gives you a well-framed piece of information that is much easier to understand than parsing it from a readout.

10: Use Scaling Visuals

This works very well when the counter is nearing its end. Whenever another second elapses, scale up the entire counter for half a second.

In addition to color and sound, this will make it much juicier.

11: Do Something When It Reaches Zero

Make sure a timer never goes negative—this will be confusing to a lot of people and seem like a bug, as bugged, badly-created timers tend to do that.

Having it flash would be a nice touch, as it again underlines the point of it having just run out.

If you have a positive timer, it is a fun element to just let it keep running and use that element as a high-score. The game Devil Daggers does a similar thing, where the main goal is “Survive 500 seconds”.

The Smoke & Mirrors of Good Countdowns, Part 2

What Not to Do That Would Break the Player’s Trust

Once the rules of a timer have been established, they should remain roughly consistent, with more leeway being allowed the less exact a timer is. The main question should be, “How is this hidden rule benefitting the experience?”

When you slow down a timer in the last three seconds, it will make the situation more tense and give the player a few more actual seconds to accomplish things. Randomly slowing it down in the middle, or speeding it up, will only break the player’s trust in your rules.

Let’s Build It

This will continue with the code-base from the last tutorial and build on it. If you haven’t checked it out yet, do so now! You can also download the finished source on the upper right of this article.

When we last left our timer, it looked like this:

The Smoke & Mirrors of Good Countdowns, Part 2

Now, new additions will make it behave more interestingly. We will continue with the countdown.cs-script, which should look like this:

using UnityEngine;
using System.Collections;

public class Countdown : MonoBehaviour {

    float timer = 60f;
    public AudioClip soundBlip;
    
    void Update (){
    	if (timer > 0f){
            timer -= Time.deltaTime;
    	} else {
            timer = 0f;
    	}
    	
    	if (timer < 10f) {
            GetComponent<TextMesh>().color = Color.red;
    	} else if (timer < 20f) {
            GetComponent<TextMesh>().color = Color.yellow;
    	}
    	
    	GetComponent<TextMesh>().text = timer.ToString("F2");
    }
}

Make It Beep

Let’s make our timer beep every second, so that the player will know it is decreasing even without looking at it.

First,  we need a nice blip sound effect. You can find an audio file in the source files, or create a nice one using the free tool BFXR. Once you have one, copy it into your asset folder.

Add an AudioSource component to your countdown object via Component > Audio > AudioSource. Add this variable to the countdown script:

public AudioClip soundBlip;

You also need to assign the sound file to the soundBlip variable:

The Smoke &amp; Mirrors of Good Countdowns, Part 2

And add this block to the Update function:

if (Mathf.Round(timer * 100) / 100 % 1f == 0) {
    GetComponent<AudioSource>().PlayOneShot(soundBlip);
}

This will check whether the timer is at the full second mark, and if so play the sound file.

The actual workings of this code are a bit more complicated. What it does is first round the floating-point timer to two decimal points, then divide it by 1, and see if there is a remainder. If the remainder is zero, it means the timer has reached a full second, and the blip sound can be played.

If we do not do this, the system might trigger much more often, which will not be fun and conducive to a good timer.

Different Speed

This will add a multiplier which will reduce or accelerate the speed of the ticking clock. Add this variable at the beginning:

float multiplier = 0.6f;

Now find the line that reduces the timer—it’s this one:

timer -= Time.deltaTime;

And adapt it to look like this:

timer -= Time.deltaTime * multiplier;

If the multiplier is below 1.0 the timer will now run slower, and if it’s above 1.0 it will run quicker. Setting it at 1.0 will do nothing.

Try to experiment to find a good value! I feel a slightly lower speed, like 0.85f, will make the player feel the ticking clock more.

Slow Down When Low

Now that we have a multiplier component, we can change it during gameplay!

Go to the color-changing block of code:

if (timer < 10f) {
    GetComponent<TextMesh>().color = Color.red;
} else if (timer < 20f) {
    GetComponent<TextMesh>().color = Color.yellow;
}

Here we already have the conditions where a change in ticking speed would be appropriate, so we can just add it!

if (timer < 10f) {
    GetComponent<TextMesh>().color = Color.red;
    multiplier = 0.6f;
} else if (timer < 20f) {
    GetComponent<TextMesh>().color = Color.yellow;
    multiplier = 0.8f;
} else {
    multiplier = 1.0f;
}

When the timer turns yellow at 20 seconds, it will now tick with 80% speed. Once it turns red, it will go down to 60% regular speed. Otherwise, it will be set to 100% speed.

Scaling When Low

Another great way to make a timer running out of time stand out more is to scale it up every passing second when low. Since we already have code that gets triggered every second, we can adapt it further!

First, we need a function to increase and decrease the size of our display. Add this function:

private bool isBlinking = false;
private IEnumerator Blink() {
    isBlinking = true;
    float startScale = transform.localScale.x;
    transform.localScale = Vector3.one * startScale * 1.4f;
    yield return new WaitForSeconds(0.3f);
    transform.localScale = Vector3.one * startScale;
    isBlinking = false;
}

This is an IEnumerator, which is a type of function that can contain wait commands. We need the isBlinking variable to make sure we do not call it multiple times.

Once initiated, it will scale up the size of the object by the factor of 1.4f, wait 0.3 seconds, and then scale down again to the original size.

We call it using this special code:

if (Mathf.Round(timer * 100) / 100 % 1f == 0) {
    GetComponent<AudioSource>().PlayOneShot(soundBlip);

    if (timer < 10f) {
        if(!isBlinking) {
            StartCoroutine(Blink());
        }
    }
}

An IEnumerator needs to be initiated by calling it via StartCoroutine, otherwise it will not work.

The entire block will be called when a second passes, at which point we can check if the timer is low enough to make it blink.

Blink at Zero

Let’s do something when the timer runs out. Having it just sit there at zero can be boring, so let’s make it blink.

First, we’ll need another IEnumerator function:

private bool isZeroBlinking = false;
private IEnumerator ZeroBlink() {
    isZeroBlinking = true;
    GetComponent<Renderer>().enabled = false;
    yield return new WaitForSeconds(1.5f);
    GetComponent<Renderer>().enabled = true;
    yield return new WaitForSeconds(1.5f);
    isZeroBlinking = false;
}

This will turn the timer on and off in 1.5-second intervals. Trigger it in the block that already checks if the timer is zero.

if (timer > 0f){
    timer -= Time.deltaTime * multiplier;
} else {
    timer = 0f;
    if(!isZeroBlinking) {
        StartCoroutine(ZeroBlink());
    }
}

Before running it, we need to disable the beeping and blinking at zero itself, otherwise that will collide behaviors.

Adapt the conditions in the block to check if a second has passed and also to check if the current time is more than zero:

if (Mathf.Round(timer * 100) / 100 % 1f == 0 && timer > 0f) {
    GetComponent<AudioSource>().PlayOneShot(soundBlip);
    
    if (timer < 10f && timer > 0f) {
        if(!isBlinking) {
            StartCoroutine(Blink());
        }
    }
}

This will make sure regular blinking and zero blinking work without interfering with each other.

The entire countdown script should look like this:

using UnityEngine;
using System.Collections;

public class Countdown : MonoBehaviour {
    
    float timer = 5f;
    float multiplier = 0.6f;
    public AudioClip soundBlip;
    
    void Update (){
        if (timer > 0f){
            timer -= Time.deltaTime * multiplier;
        } else {
            timer = 0f;
            if(!isZeroBlinking) {
                StartCoroutine(ZeroBlink());
            }
        }
    
        if (timer < 10f) {
            GetComponent<TextMesh>().color = Color.red;
            multiplier = 0.6f;
        } else if (timer < 20f) {
            GetComponent<TextMesh>().color = Color.yellow;
            multiplier = 0.8f;
        } else {
            multiplier = 1.0f;
        }
    
        if (Mathf.Round(timer * 100) / 100 % 1f == 0 && timer > 0f) {
            GetComponent<AudioSource>().PlayOneShot(soundBlip);
            
            if (timer < 10f && timer > 0f) {
                if(!isBlinking) {
                    StartCoroutine(Blink());
                }
            }
        }
    
        GetComponent<TextMesh>().text = timer.ToString("F2");
    }
    
    private bool isBlinking = false;
    private IEnumerator Blink() {
        isBlinking = true;
        float startScale = transform.localScale.x;
        transform.localScale = Vector3.one * startScale * 1.4f;
        yield return new WaitForSeconds(0.3f);
        transform.localScale = Vector3.one * startScale;
        isBlinking = false;
    }
    
    private bool isZeroBlinking = false;
    private IEnumerator ZeroBlink() {
        isZeroBlinking = true;
        GetComponent<Renderer>().enabled = false;
        yield return new WaitForSeconds(1.5f);
        GetComponent<Renderer>().enabled = true;
        yield return new WaitForSeconds(1.5f);
        isZeroBlinking = false;
    }
}

This will get the job done. You can, of course, make it more elegant and combine the commands into a more efficient code.

Conclusion

Our standard little countdown has become much more interesting and engaging. If you build this once, you can use it and plug it into any project you make, no matter how small.

Now go and put it in a game!