Coding into the Void

Coding into the Void

A blog that I’ll probably forget about after making thirty-one posts.

Better Text Boxes in Unity Part 2: Pacing Text

Now that you’ve got proper wrapping on your text box, you’ve gotten a taste for it: text boxes that don’t hinder enjoyment, but enhance it.

Uniform text reveal speed has a certain flair to it—the allure of a cinematic text scrawl—but if you want your text to feel like dialogue, you need to mimic the flow of conversation.

To do that, you’ll need to account for the ebbs and flows of speech. Spaces have no delay. Commas have a delay slightly longer than letters, and periods longer than commas. It’s relatively simple, but you’ll end up tweaking it over time to account for various corner cases.

In this post I’ll be walking through the relatively simple rules I have for animation timing, the thought process behind all of them, and, of course, examples at each step. Unlike the previous post, this is largely language and engine-agnostic, so implementation should be relatively straightforward.

I will no longer maintain the standard Unity text representation and instead just modify the TextMesh Pro section to demonstrate where you’d add the hooks for this.1

Adding the Hooks

Remember the code from last post? Here it is:

IEnumerator UpdateText(TMP_Text textObj, String textToDisplay) {
    textObj.text = textToDisplay;
    textObj.maxVisibleCharacters = 0;
    while (textObj.maxVisibleCharacters < textObj.characterCount) {
        textObj.maxVisibleCharacters += 1;
        yield return new WaitForSeconds(0.1f);
    }
}

We’re going to modify so that the time we’re waiting is dependent on a method instead of the constant 0.1f.

IEnumerator UpdateText(TMP_Text textObj, String textToDisplay) {
    textObj.text = textToDisplay;
    textObj.maxVisibleCharacters = 0;

    // Text is processed just before a frame is rendered, so we may need to
    // force an update to get the correct parsed text.
    textObj.ForceMeshUpdate();
    string fullText = textObj.GetParsedText();

    int idx = 0;
    for (int i = 0; i < textObj.characterCount; i++)
        textObj.maxVisibleCharacters = i;

        yield return Delay(
            i >= 0 && i < fullText.Length ? fullText[i] : '\0',
            (i + 1) >= 0 && (i + 1) < fullText.Length ? fullText[i + 1] : '\0',
            (i + 2) >= 0 && (i + 2) < fullText.Length ? fullText[i + 2] : '\0',
        );
    }
}

private float Delay(char prev, char curr, char next) {
    return 0.1f;
}

GetParsedText() returns the text with all the tags stripped (necessary or else we’ll mismatch with the values of maxVisibleCharacters). The previous, current, and next characters are passed in—replaced with the null terminator, \0—if out of bounds.

With a constant delay, your text box animation would look something like this (click to animate text):

It’s functional, but it’s missing a sense of panache.

The Bare Minimum

For the simplest of implementations, only the current (the upcoming character) needs to be passed. Let’s make a version that just looks at the current character to see how it behaves.2

private float Delay(char prev, char curr, char next) {
    switch (curr) {
        case '\0':
        case ' ':
        case '\n':
            return 0f;
        case '.':
        case '!':
        case '?':
        case ';':
            return 0.2f;
        case ':':
        case '—':
        case ',':
            return 0.1f;
        default:
            return 0.015f;
    }
}

Full statements are separated by a longish delay—in this case a fifth of a second (although the demo timing is doubled to emphasize the timing). Parentheticals and dependent clauses get half of the that time, at a tenth of a second. These are long enough that the reader will notice the punctuation, but not long enough to think the text box has finished.

Letters get a frame or two. Spaces get nothing, as when speaking naturally we don’t leave pauses between words. The goal is to give the animation the flow of a conversation, while making sure that the reader doesn’t have to pause while reading to wait for the text to animate.3

At this step, your text box animation would look something like this (click to animate text):

This reads more like it would if someone was telling a story, but it feels awkward in some ways; the closing quote after the exclamation point is just sort of dropped in at the end, which makes it unclear as to whether there’s more to the quote. Similarly, the gap between the opening quote and the first letter doesn’t make sense: the quotation mark is unvoiced, so no additional gap would be present.

This last one is harder to tell, but there is still a delay after the closing period, so if you skip the text while it’s processing that delay, it will appear as if your click did nothing.

A Bit Better

Fixing these issues is somewhat easy, although it does require some manual tweaking (and both the prev and next variables that we already passed in).4 In cases where the character should be immediately printed, like quotes, we do so, but we then apply what the preceding characters delay would have been to the “skipped” character.

private float Delay(char prev, char curr, char next) {
    // It looks odd to have extra time for a closing brace or quote, so apply 
    // the previous time (which was skipped) to play them together. Since there 
    // is no default case, this isn't rendering the later switch redundant.
    switch (curr) {
        case ')':
        case '"':
        case ']':
        case '}':
            // This will get the delay of the previous character in a situation
            // that will ensure that a delay occurred: coming before a space.
            return timing('\0', prev, ' ', timing);
    }

    // If the next character is one that we will handle above, immediately move 
    // on to the next character. This character's delay will get added on at 
    // that phase.
    switch (next) {
        case ')':
        case '"':
        case ']':
        case '}':
        case '\0':
            return 0;
    }

    switch (curr) {
        ... the previous switch contents
    }
}

There are some conditions that will throw off our timing, like multiple quotation marks in a row, but these shouldn’t happen in normal writing. If you want that behavior, you should be able to tweak it to your liking, either by adding some delay to those items, or providing more historical context so that you can backtrack more. It will make it slightly less efficient, but this code is unlikely to be your bottleneck.

At this step, your text box animation would look something like this (click to animate text):

There’s just one final item that irks me in the timing for this; ellipses (...) are just so slow. Yes, it’s a purposeful trailing off, but it lures you into thinking it’s a full stop and then it slowly reveals its true nature. This doesn’t match what happens in speaking, so let’s try to make it flow a little better.5

Flow, Finally

To fix this, all we need to do is decrease the amount of time when a period precedes or follows another period, something that generally only happens with ellipses. This is trivial to do with a minor modification to the switch statement to capture that scenario. I’d prefer to use a fallthrough in this situation, but C# discourages that code style, so we’ll instead explicitly return its longer delay.

private float Delay(char prev, char curr, char next) {
    ... the added code from the previous block

    switch (curr) {
        case '\0':
        case ' ':
        case '\n':
            return 0f;
        case '.':
            if (prev == '.' || next == '.') return 0.1f;
            return 0.2f;
        case '!':
        case '?':
        case ';':
            return 0.2f;
        case ':':
        case '—':
        case ',':
            return 0.1f;
        default:
            return 0.015f;
    }
}

It feels pretty good, although I’d suggest speeding it up dramatically to avoid irking your faster readers. You can cycle through all the different styles with right click and animate text with left:

Text Noises

With your new pacing, you might want to give the text box a voice. You can unmute the text boxes above by pressing ‘M’. The naive timing is a muddled mess, but once the delays are added it starts to have the cadence of speech.

You’ll want to avoid playing the text sound if there’s no delay, like for a space, and you may want to consider only playing the sound when it’s characters being revealed, not punctuation. The implementation plays a sound for all non-zero delays.

Text blips are a great way to add a bit of character to your voice, as you can change the pitch, intensity, and timbre of the sound to evoke different personalities. You may also want to add a minimum delay between playing text sounds to give them more time to breathe, especially if you have a faster text reveal.

Limitations

Indexing into the string works for English text, but it won’t work for some other languages. C# uses unicode for its underlying text representation, but many unicode characters are longer than 8 bits (a char length), and unicode characters can have modifiers that make any single rendered character many underlying characters.6

I won’t be addressing this, and it will break animation. To fix this, you would want to modify the code to get a whole displayed character at once, instead of just one byte at a time.

Furthermore, these timings above are all tuned for English—punctuation characters like ¡ will be given the length for a default character, while it’s probably more natural to treat it as you would an opening bracket.7 Translation is hard, so you’ll want to talk to a translator to get an idea of what is natural. Sadly, as I am but a humble hobbyist, translating my games has never been in the cards.

Conclusion

Between proper wrapping and a nice pace to your characters, you now have a text box that’s better than the majority of the attempts on the market. There’s still more you can do: allow users to customize text speed, support skipping text, embedding sound effects: your imagination is the limit.

May your text boxes be transcendent.


  1. Although, for the examples, I’m just using Phaser 3. The repo for that is here. ↩︎

  2. The signature matches the final one to avoid having to rewrite the preceding code. ↩︎

  3. This is poison to your game experience. Don’t do it. If you want to have a character that talks ponderously, use them sparingly and know that a certain number of your players will get disproportionately annoyed. ↩︎

  4. Note that some cases have been omitted to cut down on the code length, like []{}. There are probably some other punctuations that you’d want to include, but they don’t tend to come up in English conversation. ↩︎

  5. Alternatively, just use the ellipses character, . I don’t think it conveys trailing off as well, so I just stick with the triple periods. ↩︎

  6. Diacritic marks are one case that you might be familiar with. ↩︎

  7. Don’t take this as gospel, as other languages might have different norms. ↩︎