Debugging the Narration System: From Invisible UI to Working Text Display

How we fixed a UI rendering issue in Godot 4 by leveraging existing scene structure

Godot Version: 4.5
Renderer: GL Compatibility
Project Type: 3D Game with UI Overlay

The Problem

We built a complete narration system for Mission 01: “First Lift” – a flexible system that could display cold open text, dialogue, radio messages, objectives, and mission stats. The code was working correctly – messages were being queued, the UI was being instantiated, and all the logic was executing. But nothing was appearing on screen.

The console showed:

[NarrationSystem] UI exists: true
[NarrationSystem] UI has show_message method: true
[NarrationSystem] Calling ui.show_message()...
[NarrationUI] show_message() called, type: 1
[NarrationUI] cold_open_label is null: false

Everything seemed fine, but the text was invisible.

The Investigation

Hypothesis 1: Nodes Not Found

Initially, we thought the UI nodes weren’t being found. The @onready variables were null, so we added fallback initialization using get_node_or_null(). This fixed the null reference errors, but the text still didn’t appear.

Hypothesis 2: CanvasLayer Issues

We were creating a new CanvasLayer dynamically and adding the UI to it. In a 3D game, UI needs to be in a CanvasLayer to render properly. But maybe the layer wasn’t being set up correctly?

Hypothesis 3: Scene Structure Mismatch

The UI scene file had anchors and layout modes that might not work when instantiated dynamically. We tried various anchor presets and layout modes, but nothing helped.

The Breakthrough: Use What Works

The key insight came when we manually added a test UI to the scene:

[node name="CanvasLayer" type="CanvasLayer" parent="."]
layer = 100

[node name=”Control” type=”Control” parent=”CanvasLayer”]

layout_mode = 3 anchors_preset = 0

[node name=”Label” type=”Label” parent=”CanvasLayer/Control”]

text = “TEST Can you see this???” theme_override_font_sizes/font_size = 50

This worked perfectly! The text appeared immediately.

The solution became clear: instead of creating a new CanvasLayer, we should find and use the existing one that we know works.

The Fix

We modified NarrationSystem._setup_ui() to search for an existing CanvasLayer before creating a new one:

func _setup_ui() -> void:
	ui = get_tree().get_first_node_in_group("narration_ui")
	
	if not ui:
		# First, try to find existing CanvasLayer in the scene
		var existing_canvas_layer: CanvasLayer = null
		var root = get_tree().root
		
		# Search for CanvasLayer in the scene
		for child in root.get_children():
			if child is CanvasLayer:
				existing_canvas_layer = child
				break
		
		# If no CanvasLayer found, search in current scene
		if not existing_canvas_layer:
			var current_scene = get_tree().current_scene
			if current_scene:
				existing_canvas_layer = current_scene.get_node_or_null("CanvasLayer")
		
		# If still no CanvasLayer, create one
		if not existing_canvas_layer:
			existing_canvas_layer = CanvasLayer.new()
			existing_canvas_layer.name = "NarrationCanvasLayer"
			existing_canvas_layer.layer = 100
			root.add_child(existing_canvas_layer)
		
		# Create UI from scene
		var ui_scene = load("res://narration/narration_ui.tscn")
		if ui_scene:
			ui = ui_scene.instantiate()
			existing_canvas_layer.add_child(ui)  # Add to existing CanvasLayer!
			ui.add_to_group("narration_ui")

Why This Works

  1. Reuses Working Infrastructure: By using the existing CanvasLayer that we know works, we avoid any setup issues
  2. Consistent Rendering: The UI renders in the same layer as other UI elements, ensuring consistent visibility
  3. Simpler Scene Tree: One CanvasLayer for all UI instead of multiple layers
  4. Proven Structure: We’re using a structure that’s already been tested and verified

Godot-Specific Technical Details

CanvasLayer in 3D Games

In Godot, when you have a 3D scene (Node3D root), UI elements (Control nodes) need to be in a CanvasLayer to render properly. The CanvasLayer creates a separate 2D rendering context that overlays on top of the 3D viewport.

Key Points:

  • CanvasLayer.layer property determines rendering order (higher = on top)
  • Each CanvasLayer is independent – UI in one layer won’t interact with UI in another
  • Control nodes added directly to a 3D scene root won’t render correctly
  • Always use CanvasLayer for UI in 3D games

Common Godot UI Pitfalls

1. @onready Variables and Dynamic Instantiation

When you instantiate a scene dynamically, @onready variables are initialized after the node enters the scene tree but before _ready() is called. However, if the node paths don’t match or the scene structure is different, @onready can silently fail.

Solution: Always add fallback initialization:

@onready var my_label: Label = $MyLabel

func _ready() -> void:
    if not my_label:
        my_label = get_node_or_null("MyLabel")
        if not my_label:
            push_error("MyLabel not found!")

2. Layout Mode and Anchors

Godot 4 has different layout modes:

  • layout_mode = 0 (Container) – Uses anchors and offsets
  • layout_mode = 1 (Anchors) – Uses anchor presets
  • layout_mode = 3 (Full Rect) – Full screen coverage

When instantiating dynamically, explicit offsets work more reliably than anchor presets:

# More reliable for dynamic instantiation
layout_mode = 0
offset_left = -200.0
offset_right = 200.0
offset_top = -50.0
offset_bottom = 50.0

# vs anchor presets (can be unreliable)
layout_mode = 1
anchors_preset = 15  # Full rect

3. Tween API Changes in Godot 4

Godot 4’s Tween API is different from Godot 3:

  • ❌ tween.tween_delay() – Doesn’t exist!
  • ✅ await get_tree().create_timer(time).timeout – Use this instead

4. Named Parameters

GDScript doesn’t support Python-style named parameters:

# ❌ This doesn't work
my_function(param1=value1, param2=value2)

# ✅ Use positional arguments
my_function(value1, value2)

5. get_tree() Null Checks

When nodes are instantiated dynamically, get_tree() might be null if the node isn’t in the scene tree yet:

# Always check before using get_tree()
if is_inside_tree():
    var tree = get_tree()
    if tree:
        await tree.create_timer(1.0).timeout
else:
    # Fallback for nodes not in tree
    for i in range(60):
        await Engine.get_main_loop().process_frame

Best Practices for Dynamic UI in Godot

  1. Find Existing CanvasLayer First: Search for existing CanvasLayer before creating new ones
  2. Use Explicit Offsets: More reliable than anchor presets for dynamically created UI
  3. Add Null Checks: Always verify nodes exist before accessing them
  4. Test with Manual UI: Create a simple test UI in the scene first to verify rendering works
  5. Check Scene Tree: Use print_tree() or iterate children to verify node structure

Additional Fixes

While debugging, we also fixed several other issues:

Tween API Compatibility

Godot 4 doesn’t have tween_delay(). We replaced it with:

# Old (Godot 3 style - doesn't work)
await tween.tween_delay(wait_time)

# New (Godot 4)
await get_tree().create_timer(wait_time).timeout

Note: Always check is_inside_tree() and get_tree() before using timers:

if is_inside_tree():
    var tree = get_tree()
    if tree:
        await tree.create_timer(wait_time).timeout

Null Safety

Added comprehensive null checks and fallback node initialization to handle cases where @onready variables might not initialize properly.

GDScript Syntax

Fixed named parameter usage – GDScript doesn’t support Python-style named parameters, so we switched to positional arguments:

# ❌ This causes "Assignment is not allowed inside an expression" error
NarrationSystem.show_cold_open(
    text="Hello",
    duration=5.0,
    fade_in=1.0
)

# ✅ Use positional arguments instead
NarrationSystem.show_cold_open(
    "Hello",
    5.0,  # duration
    1.0   # fade_in
)

Lessons Learned

  1. Test with Manual UI First: Creating a simple test UI in the scene helped us identify that the problem wasn’t with UI rendering in general, but with how we were creating the UI dynamically. This is a crucial debugging step in Godot.
  2. Reuse Working Code: Instead of creating new infrastructure, we leveraged what already worked. This is often faster and more reliable than debugging new code. In Godot, reusing existing CanvasLayers is a common pattern.
  3. Debug Systematically: We added instrumentation to track:
    • Node initialization
    • UI creation
    • Message flow
    • Visibility states
    Godot’s print() and push_error() are your friends – use them liberally during debugging.
  4. Check the Obvious: Sometimes the issue isn’t with your code, but with how it integrates with the existing scene structure. Always verify:
    • Is the node in the scene tree? (is_inside_tree())
    • Does get_tree() return a valid SceneTree?
    • Are parent nodes visible?
    • Is the CanvasLayer layer value correct?
  5. Godot-Specific Gotchas:
    • @onready can silently fail – always add fallback get_node_or_null()
    • Control nodes need explicit size/position when created dynamically
    • CanvasLayer is required for UI in 3D scenes
    • Tween API changed significantly in Godot 4

The Result

The narration system now works perfectly:

  • ✅ Cold open text displays on mission start
  • ✅ Dialogue boxes appear with speaker names
  • ✅ Radio messages show with proper styling
  • ✅ Objectives update in the HUD
  • ✅ Mission stats display at the end

All using the existing CanvasLayer that we know works, ensuring consistent rendering and visibility.

Code Structure

The final system consists of:

  • NarrationSystem (autoload singleton) – Manages message queue and coordinates with UI
  • NarrationMessage (resource) – Data structure for messages
  • NarrationUI (scene + script) – UI controller that displays messages
  • Mission01Controller – Mission-specific logic that triggers narration events

The system is flexible, extensible, and ready to support future missions with different narration requirements.

Quick Reference for Godot Developers

Creating UI in 3D Games – Checklist

  • [ ] UI is in a CanvasLayer (not directly in 3D scene)
  • [ ] CanvasLayer has appropriate layer value (higher = on top)
  • [ ] Control nodes have explicit size/position or proper anchors
  • [ ] @onready variables have fallback initialization
  • [ ] get_tree() is checked before use
  • [ ] Tween operations use Godot 4 API (create_timer, not tween_delay)
  • [ ] Named parameters are avoided (use positional arguments)

Debugging UI Issues – Step by Step

  1. Create a simple test UI manually in your scene to verify rendering works
  2. Check if nodes existprint(node != null) or use get_node_or_null()
  3. Verify scene treeprint_tree() or iterate children
  4. Check visibilityprint(node.visible)print(node.modulate)
  5. Verify CanvasLayerprint(get_parent() is CanvasLayer)
  6. Check sizeprint(node.size) – zero size means invisible
  7. Test with explicit values: Set position/size manually to rule out anchor issues

Code Pattern: Safe UI Node Access

@onready var my_label: Label = $MyLabel

func _ready() -> void:
    # Fallback if @onready failed
    if not my_label:
        my_label = get_node_or_null("MyLabel")
    
    if not my_label:
        push_error("MyLabel not found!")
        return
    
    # Now safe to use
    my_label.text = "Hello"
    my_label.visible = true

Code Pattern: Finding Existing CanvasLayer

func find_or_create_canvas_layer() -> CanvasLayer:
    # Check root children
    var root = get_tree().root
    for child in root.get_children():
        if child is CanvasLayer:
            return child
    
    # Check current scene
    var current_scene = get_tree().current_scene
    if current_scene:
        var layer = current_scene.get_node_or_null("CanvasLayer")
        if layer:
            return layer
    
    # Create new one
    var new_layer = CanvasLayer.new()
    new_layer.name = "UICanvasLayer"
    new_layer.layer = 100
    root.add_child(new_layer)
    return new_layer

This debugging session taught us the value of leveraging existing working infrastructure rather than creating new systems from scratch. Sometimes the best fix is the simplest one.

For Godot developers: Always test UI rendering with a simple manual setup first. If that works, the issue is with how you’re creating UI dynamically, not with Godot’s rendering system.


Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *