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
- Reuses Working Infrastructure: By using the existing CanvasLayer that we know works, we avoid any setup issues
- Consistent Rendering: The UI renders in the same layer as other UI elements, ensuring consistent visibility
- Simpler Scene Tree: One CanvasLayer for all UI instead of multiple layers
- 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.layerproperty 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 offsetslayout_mode = 1(Anchors) – Uses anchor presetslayout_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
- Find Existing CanvasLayer First: Search for existing CanvasLayer before creating new ones
- Use Explicit Offsets: More reliable than anchor presets for dynamically created UI
- Add Null Checks: Always verify nodes exist before accessing them
- Test with Manual UI: Create a simple test UI in the scene first to verify rendering works
- 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
- 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.
- 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.
- Debug Systematically: We added instrumentation to track:
- Node initialization
- UI creation
- Message flow
- Visibility states
print()andpush_error()are your friends – use them liberally during debugging. - 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?
- Is the node in the scene tree? (
- Godot-Specific Gotchas:
@onreadycan silently fail – always add fallbackget_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
layervalue (higher = on top) - [ ] Control nodes have explicit size/position or proper anchors
- [ ]
@onreadyvariables have fallback initialization - [ ]
get_tree()is checked before use - [ ] Tween operations use Godot 4 API (
create_timer, nottween_delay) - [ ] Named parameters are avoided (use positional arguments)
Debugging UI Issues – Step by Step
- Create a simple test UI manually in your scene to verify rendering works
- Check if nodes exist:
print(node != null)or useget_node_or_null() - Verify scene tree:
print_tree()or iterate children - Check visibility:
print(node.visible),print(node.modulate) - Verify CanvasLayer:
print(get_parent() is CanvasLayer) - Check size:
print(node.size)– zero size means invisible - 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.
Leave a Reply