Quick answer: The most common cause is that the effect is not registered with the RichTextLabel node. You must add your RichTextEffect instance to the custom_effects array on the RichTextLabel via the inspector or code.

Here is how to fix Godot richtext effect custom not working. You created a custom RichTextEffect class, added your BBCode tag to the text, and nothing happens. The text renders as plain text with the tag visible, or the tag disappears but _process_custom_fx is never called. Custom BBCode effects in Godot 4 require several pieces to line up correctly, and missing any one of them causes silent failure.

How Custom RichTextEffects Work in Godot 4

A custom RichTextEffect is a GDScript or C# class that extends RichTextEffect. It defines a bbcode property that determines the tag name and a _process_custom_fx method that runs per character per frame. For the effect to work, three things must be true simultaneously: the effect must be registered with the RichTextLabel, BBCode must be enabled, and the tag in the text must exactly match the bbcode property.

Here is a minimal working example of a shake effect:

# shake_effect.gd
@tool
class_name ShakeEffect
extends RichTextEffect

var bbcode := "shake"

func _process_custom_fx(char_fx: CharFXTransform) -> bool:
    var freq := char_fx.env.get("freq", "4.0").to_float()
    var amp := char_fx.env.get("amp", "3.0").to_float()
    var offset := Vector2(
        randf_range(-amp, amp),
        randf_range(-amp, amp))
    char_fx.offset += offset
    return true

And the RichTextLabel BBCode text would be:

This text is normal. [shake freq=6.0 amp=2.0]This text shakes![/shake]

Fix 1: Register the Effect with the RichTextLabel

The most common reason a custom effect does not work is that it was never added to the RichTextLabel’s custom_effects array. Creating the script alone is not enough—you must explicitly install it.

Via the Inspector: Select your RichTextLabel node, find the custom_effects property under the BBCode section, click the array to expand it, and add a new element. Drag your effect script or create a new instance of your effect class.

Via code:

extends Control

@onready var label := $RichTextLabel

func _ready() -> void:
    # Create and register the effect
    var shake := ShakeEffect.new()
    label.install_effect(shake)

    # Now set the text with BBCode
    label.text = "Normal text. [shake]Shaking text![/shake]"

You can also use label.custom_effects.append(shake), but install_effect() is the preferred API as it handles internal registration properly.

Fix 2: Enable BBCode on the RichTextLabel

Custom effects only work when bbcode_enabled is true. If BBCode is disabled, the RichTextLabel renders text as plain text and ignores all tags, including custom ones.

func _ready() -> void:
    var label := $RichTextLabel
    # This is required for custom effects
    label.bbcode_enabled = true
    label.text = "[shake]This only works with bbcode_enabled = true[/shake]"

In the inspector, this is the BBCode > Enabled checkbox. It defaults to false on new RichTextLabel nodes, which trips up developers who expect it to be on by default.

Fix 3: Match the bbcode Property to the Tag Name

The bbcode variable in your RichTextEffect class must exactly match the tag you use in the BBCode text. This is case-sensitive and must not include brackets.

# WRONG: bbcode includes brackets
var bbcode := "[wobble]"  # Will NOT match [wobble]...[/wobble]

# WRONG: bbcode has extra spaces
var bbcode := " shake "  # Will NOT match [shake]...[/shake]

# CORRECT: plain tag name, no brackets, no spaces
var bbcode := "wobble"  # Matches [wobble]...[/wobble]

If you rename your tag, update both the bbcode property in the script and every occurrence in your RichTextLabel text. A mismatch will cause the tag to either render as literal text or be stripped with no effect applied.

Fix 4: Add the @tool Annotation

If you want to see your custom effect in the editor (not just at runtime), your script must have the @tool annotation at the top. Without @tool, the effect only runs during gameplay.

@tool  # Required for editor preview
class_name WaveEffect
extends RichTextEffect

var bbcode := "wave"

func _process_custom_fx(char_fx: CharFXTransform) -> bool:
    var speed := char_fx.env.get("speed", "5.0").to_float()
    var height := char_fx.env.get("height", "4.0").to_float()
    char_fx.offset.y += sin(
        char_fx.elapsed_time * speed + char_fx.relative_index * 0.5
    ) * height
    return true

Note that @tool makes the entire script run in the editor. If your effect script has side effects in _ready or other lifecycle methods, guard them with if Engine.is_editor_hint(): return to prevent unexpected behavior.

Fix 5: Return true from _process_custom_fx

The _process_custom_fx method must return true to indicate that the character should be rendered. If you forget the return statement (which defaults to returning null / falsy) or explicitly return false, the characters inside your tag will become invisible.

# BAD: missing return — characters disappear
func _process_custom_fx(char_fx: CharFXTransform) -> bool:
    char_fx.offset.y += 5.0
    # No return statement = returns null = characters hidden

# GOOD: explicit return true
func _process_custom_fx(char_fx: CharFXTransform) -> bool:
    char_fx.offset.y += 5.0
    return true

Returning false is only useful if you intentionally want to hide specific characters as part of a typewriter or reveal effect.

Working with Effect Parameters

Custom BBCode tags can accept parameters using the [tag key=value] syntax. All parameter values arrive as strings in the char_fx.env dictionary and must be converted manually:

@tool
class_name ColorPulseEffect
extends RichTextEffect

var bbcode := "pulse"

func _process_custom_fx(char_fx: CharFXTransform) -> bool:
    var speed := char_fx.env.get("speed", "3.0").to_float()
    var color_str := char_fx.env.get("color", "ff0000")
    var pulse_color := Color(color_str)

    var t := abs(sin(char_fx.elapsed_time * speed))
    char_fx.color = char_fx.color.lerp(pulse_color, t)
    return true

# Usage: [pulse speed=2.0 color=ff6600]Pulsing orange text[/pulse]

Always provide default values via .get(key, default) to handle cases where the user omits a parameter. This prevents crashes from missing dictionary keys.

“Custom RichTextEffects are one of Godot’s most powerful UI features, but the setup has too many silent failure modes. Check registration, check bbcode_enabled, check the return value, and check the tag name. In that order.”

Related Issues

If you are building a dialogue system with rich text effects and running into other issues, see Fix: Godot HTTPRequest Not Completing or Timing Out for fetching dialogue data from a server. For collecting UI rendering bugs from players, check out How to Add a Bug Reporter to a Godot Game.

The RichTextEffect system is elegant once it works. The debugging frustration comes from Godot not telling you what is wrong. Follow the checklist: register, enable, match, return true.