github

Typewriter Effect in Godot

Text reveal effects are everywhere in games — dialogue boxes, terminal screens, narrative beats. The classic typewriter effect makes text feel alive instead of just appearing. Godot's RichTextLabel has a property that makes this surprisingly straightforward to build, and with a Curve resource on top you get full artistic control over the pacing.

Here's how I built a reusable typewriter effect in Godot 4 with C#.

The Core Idea: VisibleRatio

RichTextLabel exposes a VisibleRatio property — a float from 0 to 1 that controls how much of the text is visible. Set it to 0 and nothing shows. Lerp it to 1 over time and you get a text reveal.

That's it. That's the entire trick:

float t = Mathf.Clamp(elapsed / duration, 0f, 1f);
label.VisibleRatio = t;

A linear ramp from 0 to 1. Works, but it's boring — every character appears at the same rate. Long sentences feel sluggish at the end, short ones feel rushed. You want pacing.

Curves for Pacing

Godot's Curve resource is an underrated tool. It's an editable spline that maps an input (0-1) to an output (0-1), and it comes with a nice editor right in the inspector.

Instead of feeding t directly to VisibleRatio, sample a curve:

if (curve != null)
    label.VisibleRatio = curve.SampleBaked(t);
else
    label.VisibleRatio = t;

Now you can shape the reveal however you want:

  • Ease-in: Slow start, builds momentum — good for dramatic reveals
  • Ease-out: Fast start, lingers on the last words — natural reading pace
  • S-curve: Slow start and end, fast middle — feels the most "typed"
  • Custom: A dip in the middle for a dramatic pause? A burst at the start? Go wild

The curve is optional — without one you get the linear fallback. But once you wire it up as a parameter, designers (or future-you) can tweak the feel without touching code.

Sound: Character-Driven, Not Time-Driven

A typewriter effect without sound is only half the experience. The key detail: trigger sounds when new characters appear, not on a fixed timer.

int visibleChars = label.VisibleCharacters;
if (visibleChars > lastVisibleChars)
{
    TryPlaySound();
    lastVisibleChars = visibleChars;
}

VisibleCharacters is the integer companion to VisibleRatio — it tells you exactly how many characters are showing. When that number jumps, a new character appeared and that's when you play the click.

This matters because with curve-based pacing, characters don't appear at even intervals. If you played sound on a timer, the audio would drift out of sync with the text during fast or slow sections.

Sound Cooldown

One gotcha: if a burst of characters appears in a single frame (fast-forward, or a steep section of the curve), you don't want a burst of overlapping sound effects. A small cooldown solves it:

soundCooldownRemaining -= delta; // Real delta, not affected by fast-forward

if (soundCooldownRemaining > 0) return;
soundCooldownRemaining = soundCooldown; // ~0.08s works well

var sound = sounds[rng.RandiRange(0, sounds.Length - 1)];
audioPlayer.PitchScale = (float)GD.RandRange(1f - pitchVariation, 1f + pitchVariation);
audioPlayer.Play();

Notice the cooldown ticks with real delta, not the fast-forwarded delta. This keeps the sound density consistent whether the player is holding the skip button or not.

Pitch variation is subtle but important — even 5% randomization prevents the robotic "same click repeating" effect. Pick 3-4 slightly different click samples and randomize both the sample and the pitch. It sounds way more organic than you'd expect.

Fast-Forward

Players want control. Holding a button to speed through text they've already read (or text they just want to skip) is a standard expectation.

The implementation is simple — multiply the delta:

float effectiveDelta = fastForward ? delta * FastForwardMultiplier : delta;
elapsed += effectiveDelta;

A 4x multiplier feels right — fast enough to feel responsive, slow enough that the player can still catch the text if they want. You also want a way to instantly complete the text on a second press (skip vs. fast-forward), but that's a UX decision for the system using the effect, not the effect itself.

Making It Reusable

The typewriter effect doesn't own the label. It doesn't know about dialogue systems or note UIs. It's a Node you add as a child, hand it a RichTextLabel reference, and call Update(delta) from your _Process. That's the interface:

// Start a reveal
typewriter.Start(label, duration: 2.0f, curve: myCustomCurve);

// Each frame
typewriter.Update(delta);

// Player interaction
typewriter.SetFastForward(true);

// Query state
if (typewriter.IsComplete) { /* advance dialogue */ }

This makes it composable. My dialogue system uses it with per-line curves. My note viewer uses it with a flat duration. Neither needs to know how the other works — they just share the same effect node.

Wrapping Up

The ingredients:

  1. VisibleRatio — Godot gives you the lever, you just need to pull it
  2. Curve resources — artistic control over pacing without code changes
  3. Character-driven sound — sync to VisibleCharacters, not to time
  4. Sound cooldown on real delta — keeps audio consistent across speed changes
  5. Separation of effect from system — one node, many consumers

The whole implementation is about 150 lines of C#. Most of the "feel" comes from the curve and the audio tuning, not from code complexity. That's the nice part — once the skeleton works, iteration is all in the inspector.

Full source code