Skip to main content
This example demonstrates how to create dialogue options with time limits, showing a complete implementation with visual feedback, progress bars, and automatic option selection when time runs out.

Overview

In this example, you’ll learn how to:
  • Create timed dialogue options
  • Use Godot’s Tween system with dialogue
  • Show and hide options dynamically
  • Display a timer/progress bar
  • Auto-select options when time expires
  • Handle complex animation sequences
  • Coordinate multiple UI elements

The Dialogue

extends DialogueEngine

enum {
    DEFAULT_TOPIC = 0,
    WATCH_THE_STORM,
    GO_BACK_TO_SLEEP,
    KEEP_WORKING,
}

func _setup() -> void:
    var entry: DialogueEntry = add_text_entry("The storm rages right outside the window. I should...")
    var option_id_1: int = entry.add_option("Wait for storm to finish.")
    var option_id_2: int = entry.add_option("Go back to sleep.")
    var option_id_3: int = entry.add_option("Get back to work.")
    var option_id_4: int = entry.add_option("Hidden option -- this should not be shown on the UI")
    entry.set_metadata("dont_show_options", [option_id_4])
    entry.set_metadata("auto_choose", option_id_4)
    
    var option_id_2_entry: DialogueEntry = add_text_entry("That's right, sleep is for the strong 💪.", GO_BACK_TO_SLEEP)
    entry.set_option_goto_id(option_id_2, option_id_2_entry.get_id())
    
    var option_id_3_entry: DialogueEntry = add_text_entry("That's right, let's get back to work 🫡", KEEP_WORKING)
    entry.set_option_goto_id(option_id_3, option_id_3_entry.get_id())
    
    var option_id_4_entry: DialogueEntry = add_text_entry("I think I'll enjoy watching the storm for a bit...", WATCH_THE_STORM)
    entry.set_option_goto_id(option_id_4, option_id_4_entry.get_id())
    
    # Join branches into the default topic
    var default_topic: DialogueEntry = add_text_entry("Some time passes...")
    entry.set_option_goto_id(option_id_1, default_topic.get_id())
    option_id_2_entry.set_goto_id(default_topic.get_id())
    option_id_3_entry.set_goto_id(default_topic.get_id())
    option_id_4_entry.set_goto_id(default_topic.get_id())
    
    add_text_entry("<Press 'Space' or 'Enter' to quit>")
The dialogue structure is similar to the animations example, but the UI implementation adds time-based mechanics.

UI Implementation

Here’s the complete UI script with timed options:
extends VBoxContainer

@export var dialogue_gdscript: GDScript = null
var dialogue_engine: DialogueEngine = null

@onready var progress_bar: ProgressBar = $ProgressBar
@onready var vbox: VBoxContainer = $VBox

func _ready() -> void:
    dialogue_engine = dialogue_gdscript.new()
    dialogue_engine.dialogue_started.connect(__on_dialogue_started)
    dialogue_engine.dialogue_continued.connect(__on_dialogue_continued)
    dialogue_engine.dialogue_finished.connect(__on_dialogue_finished)
    dialogue_engine.dialogue_canceled.connect(__on_dialogue_canceled)

func _input(p_input_event: InputEvent) -> void:
    if p_input_event.is_action_pressed(&"ui_accept"):
        dialogue_engine.advance()
        accept_event() # to avoid hitting a button due to the input event travelling through the children

func __on_dialogue_started() -> void:
    print("Dialogue Started!")

var enabled_buttons: Array[Button] = []

func __on_dialogue_continued(p_dialogue_entry: DialogueEntry) -> void:
    var label: RichTextLabel = RichTextLabel.new()
    label.set_use_bbcode(true)
    label.set_fit_content(true)
    label.set_text("  > " + p_dialogue_entry.get_text())
    vbox.add_child(label)
    
    if p_dialogue_entry.has_options():
        var dont_show_options: Array = p_dialogue_entry.get_metadata("dont_show_options", [])
        for option_id: int in range(0, p_dialogue_entry.get_option_count()):
            if option_id in dont_show_options:
                continue
            var option_text: String = p_dialogue_entry.get_option_text(option_id)
            var button: Button = Button.new()
            button.set_text(option_text)
            vbox.add_child(button)
            var tween: Tween = create_tween()
            if option_id == 0:
                button.grab_focus()
                tween.tween_property(button, "modulate", Color.TRANSPARENT, 3.0)
                tween.tween_callback(button.hide)
            else:
                # Only show other buttons after the tween finishes
                button.hide()
                tween.tween_callback(button.show).set_delay(5.0)
                
                if option_id == 1:
                    tween.tween_callback(button.grab_focus)
                    tween.tween_callback(progress_bar.show)
                    tween.tween_method(progress_bar.set_value, 1.0, 0.0, 2.0)
                    
                    # The timer has just finished
                    tween.tween_callback(progress_bar.hide)
                    tween.tween_callback(advance_dialogue_no_answer)
            button.pressed.connect(__advance_dialogue_with_chosen_option.bind(option_id))
            enabled_buttons.push_back(button)
        set_process_input(false)

func advance_dialogue_no_answer() -> void:
    for button: Button in enabled_buttons:
        button.set_disabled(true)
    
    var entry: DialogueEntry = dialogue_engine.get_current_entry()
    var option_id: int = entry.get_metadata("auto_choose")
    entry.choose_option(option_id)
    dialogue_engine.advance()
    set_process_input(true)

func __advance_dialogue_with_chosen_option(p_option_id: int) -> void:
    # Kill all tweens from processing further
    for tween: Tween in get_tree().get_processed_tweens():
        tween.kill()
    for button: Button in enabled_buttons:
        button.set_disabled(true)
        # Reset modulate of vanishing button
        button.modulate = Color.WHITE
    enabled_buttons.clear()
    progress_bar.hide()
    
    var current_entry: DialogueEntry = dialogue_engine.get_current_entry()
    current_entry.choose_option(p_option_id)
    dialogue_engine.advance()
    
    set_process_input(true)

func __on_dialogue_finished() -> void:
    print("Dialogue Finished! Exiting...")
    get_tree().quit()

func __on_dialogue_canceled() -> void:
    print("Dialogue Canceled! Exiting...")
    get_tree().quit()

Code Breakdown

1. Scene Structure

@onready var progress_bar: ProgressBar = $ProgressBar
@onready var vbox: VBoxContainer = $VBox
The scene has two key elements:
  • ProgressBar: Shows the countdown timer
  • VBox: Container for dialogue text and option buttons

2. Timed Button Behavior

The timing sequence for options:
var tween: Tween = create_tween()
if option_id == 0:
    # First option: Fades out over 3 seconds
    button.grab_focus()
    tween.tween_property(button, "modulate", Color.TRANSPARENT, 3.0)
    tween.tween_callback(button.hide)
else:
    # Other options: Hidden initially, appear after 5 seconds
    button.hide()
    tween.tween_callback(button.show).set_delay(5.0)
Option 0: Starts visible and focused, fades out over 3 secondsOption 1+: Start hidden, become visible after 5 seconds (after option 0 has faded)

3. Progress Bar Timer

if option_id == 1:
    tween.tween_callback(button.grab_focus)
    tween.tween_callback(progress_bar.show)
    tween.tween_method(progress_bar.set_value, 1.0, 0.0, 2.0)
    
    # The timer has just finished
    tween.tween_callback(progress_bar.hide)
    tween.tween_callback(advance_dialogue_no_answer)
When the second option becomes visible:
  1. Focus the button
  2. Show the progress bar
  3. Animate progress bar from 100% to 0% over 2 seconds
  4. Hide the progress bar
  5. Call the timeout handler
tween_method is perfect for animating property values over time, like progress bars or health bars.

4. Timeout Handler

func advance_dialogue_no_answer() -> void:
    for button: Button in enabled_buttons:
        button.set_disabled(true)
    
    var entry: DialogueEntry = dialogue_engine.get_current_entry()
    var option_id: int = entry.get_metadata("auto_choose")
    entry.choose_option(option_id)
    dialogue_engine.advance()
    set_process_input(true)
When time runs out:
  1. Disable all buttons
  2. Get the auto_choose option ID from metadata
  3. Select that option automatically
  4. Advance the dialogue
  5. Re-enable input

5. Manual Selection Handler

func __advance_dialogue_with_chosen_option(p_option_id: int) -> void:
    # Kill all tweens from processing further
    for tween: Tween in get_tree().get_processed_tweens():
        tween.kill()
    for button: Button in enabled_buttons:
        button.set_disabled(true)
        # Reset modulate of vanishing button
        button.modulate = Color.WHITE
    enabled_buttons.clear()
    progress_bar.hide()
    
    var current_entry: DialogueEntry = dialogue_engine.get_current_entry()
    current_entry.choose_option(p_option_id)
    dialogue_engine.advance()
    
    set_process_input(true)
When the player clicks a button:
  1. Kill all tweens - Stop the timer and animations
  2. Disable and reset all buttons
  3. Hide the progress bar
  4. Select the chosen option
  5. Advance the dialogue
Always kill active tweens when the player makes a choice, or they’ll continue running and may trigger the timeout callback even after the player has made a selection.

Timeline Visualization

Here’s how the timing works:
0.0s: Options appear
      - Option 0: "Wait for storm to finish" (visible, focused, fading)
      - Option 1: "Go back to sleep" (hidden)
      - Option 2: "Get back to work" (hidden)

3.0s: Option 0 fully faded and hidden

5.0s: Options 1 and 2 appear
      - Option 1 gains focus
      - Progress bar appears at 100%

5.0s - 7.0s: Progress bar animates from 100% to 0%

7.0s: Timeout
      - Progress bar hides
      - Auto-select hidden option 4
      - Dialogue advances to "watching the storm" branch

Customization Examples

Simple Time Limit

For a simpler implementation with just a time limit:
func __on_dialogue_continued(entry: DialogueEntry) -> void:
    dialogue_label.text = entry.get_text()
    
    if entry.has_options():
        # Show all options immediately
        for option_id in range(entry.get_option_count()):
            create_option_button(option_id)
        
        # Start a 5-second timer
        var tween = create_tween()
        tween.tween_callback(progress_bar.show)
        tween.tween_method(progress_bar.set_value, 1.0, 0.0, 5.0)
        tween.tween_callback(on_timeout)

func on_timeout() -> void:
    progress_bar.hide()
    clear_option_buttons()
    
    var entry = dialogue_engine.get_current_entry()
    var default_option = entry.get_metadata("auto_choose", 0)
    entry.choose_option(default_option)
    dialogue_engine.advance()

Per-Option Time Limits

Use metadata to set different time limits for different choices:
func _setup() -> void:
    var entry: DialogueEntry = add_text_entry("Quick! What do you do?")
    entry.add_option("Dodge!")
    entry.add_option("Block!")
    entry.add_option("Counter!")
    entry.set_metadata("time_limit", 3.0) # 3 seconds to choose
    entry.set_metadata("auto_choose", 1) # Default to "Block"
func __on_dialogue_continued(entry: DialogueEntry) -> void:
    if entry.has_options():
        var time_limit = entry.get_metadata("time_limit", 10.0)
        start_timer(time_limit)

Visual Warning Effects

func start_timer(duration: float) -> void:
    var tween = create_tween()
    tween.tween_method(progress_bar.set_value, 1.0, 0.0, duration)
    
    # Flash red when below 30%
    tween.parallel().tween_callback(func() -> void:
        if progress_bar.value < 0.3:
            progress_bar.modulate = Color.RED
    ).set_delay(duration * 0.7)
    
    # Shake when below 10%
    tween.parallel().tween_callback(start_shake_effect).set_delay(duration * 0.9)
    
    tween.tween_callback(on_timeout)

Audio Countdown

func start_timer_with_audio(duration: float) -> void:
    var tween = create_tween()
    tween.tween_method(progress_bar.set_value, 1.0, 0.0, duration)
    
    # Play tick sounds every second
    for i in range(int(duration)):
        tween.parallel().tween_callback(play_tick_sound).set_delay(float(i))
    
    # Play final beep before timeout
    tween.tween_callback(play_timeout_sound).set_delay(duration - 0.5)
    tween.tween_callback(on_timeout)

Key Takeaways

Use Tweens for timing - Godot’s Tween system is perfect for creating animated countdowns and time-based mechanics.
Kill tweens on manual selection - Always stop active tweens when the player makes a choice to prevent double-execution.
Provide visual feedback - Use progress bars, color changes, or animations to show time passing.
Always have a fallback - Use auto_choose metadata to ensure dialogue continues even if the player doesn’t respond.
Test timing carefully - Make sure time limits are generous enough for players to read and decide.
Time limits should enhance gameplay, not frustrate players. Always test with real players to ensure timing feels fair.

Accessibility Considerations

When implementing timed options:
  • Provide an option to disable or extend timers in settings
  • Make sure timer duration is configurable
  • Use multiple visual indicators (progress bar, color, animation)
  • Consider audio cues for visually impaired players
  • Allow players to pause during timed sequences
func get_timer_duration(base_duration: float) -> float:
    var multiplier = GameSettings.timer_multiplier # e.g., 1.0, 1.5, 2.0
    return base_duration * multiplier

Next Steps

Signals Reference

Learn about all dialogue signals for advanced control

Conditional Branching

Combine timed choices with conditional logic

Build docs developers (and LLMs) love