[UE5] Building a Dialogue System: Typewriter Text and Branching Choices

Created: 2026-07-20

An illustrated guide to RPG and visual-novel dialogue: storing lines in a Data Table, revealing them one character at a time in UMG, and branching with choices. Covers two-stage advancing, stopping movement during conversation, and a hands-on branching NPC conversation.

You talk to an NPC and a long line of dialogue dumps onto the screen all at once. You want that effect where text flows in one character at a time, but you don't know how to build it. You want choices, but it seems like you'd have to line up a pile of buttons and wire each one by hand.

A dialogue system is very manageable once you combine Data Tables with UMG. This article covers where to store your lines, how to build typewriter text, the two-stage advance input, and how to branch a conversation with choices.

A dialogue window with a speech bubble and a soft blue figure being spoken to

What You'll Learn

  • Store lines in a Data Table with one row per line
  • Typewriter text means holding the full string and increasing the visible count with a Timer
  • Advancing is two-stage (mid-reveal shows the full line, already-revealed moves on)
  • Choices are buttons generated on the fly , and the chosen one changes the next row
  • Set Input Mode UI Only stops movement during conversation
  • Hands-on: talk to an NPC, start a conversation, and branch with choices

Sponsored

Where to Store Your Lines

The first decision is where you write your lines . Write them straight into the widget and you'll be opening and compiling that widget every time you add a single line. Dialogue grows to dozens or hundreds of lines, so this belongs outside.

A Data Table is the right fit. Make one row equal one line , with columns for the speaker, the body text, and the next row.

Diagram of DT_Dialogue where each row holds RowName, Speaker, Line, and NextRow, and NextRow chains to the following row. One row represents one line

First, create a struct called S_DialogueLine that defines a row's shape (creating structs is covered in the struct article).

VariableTypeContents
SpeakerTextThe name of who's talking
LineTextThe line itself
NextRowNameThe row to advance to. Leave it empty at the end of a conversation
ChoicesS_DialogueChoice ( array )Choices. Empty if there aren't any

A single choice gets its own small struct, S_DialogueChoice.

VariableTypeContents
ChoiceTextTextThe text on the button ("Yes" and so on)
TargetRowNameThe row to jump to when this is chosen

Now a conversation is expressible as movement from row to row . Ordinary lines point at the next row via NextRow, and only the rows that fork fill in Choices.

It matters that the body text and speaker name are held as Text rather than String . Text is what localization operates on, so translations can be dropped in later. String isn't part of that system (see Localizing your game).

Note: Nested arrays like Choices can't round-trip through CSV. You can bulk-edit speakers and body text in CSV, but choices have to be edited directly in the Data Table asset. Treating it as "bulk-import normal lines from CSV, add branches in the editor" is the practical compromise.

If your lines amount to a sign or a hint with only a few sentences, skip the Data Table and just pass Text straight to the widget. Let the volume of dialogue decide how heavy the machinery should be.

Sponsored

Anatomy of the Dialogue Widget

Now the visual container. Create one Widget Blueprint called WBP_Dialogue and lay out the parts you need.

Layout of WBP_Dialogue. The dialogue panel at the bottom stacks SpeakerText, BodyText, ContinueIcon, and ChoiceBox vertically, with AdvanceButton covering the full screen

Five parts go in. Check Is Variable in the Details panel for anything you'll display through , so you can reach it from the Graph.

PartTypeRole
SpeakerTextText BlockThe speaker's name, shown small above
BodyTextText BlockThe line itself. Turn on Auto Wrap Text
ContinueIconText Block ("▼")The advance cue, shown once the reveal finishes
ChoiceBoxVertical BoxWhere choice buttons get stacked
AdvanceButtonButtonA transparent button covering the whole screen, so clicking anywhere advances

Always turn on Auto Wrap Text on BodyText. UMG's Text Block doesn't wrap by default, so long lines run off the screen.

AdvanceButton is the trick behind "clicking anywhere on screen advances." Place it on the Canvas Panel, stretch its anchors to fill the screen, and set Background Color's alpha to 0 so it's invisible. The key is the stacking order in the Hierarchy: put ChoiceBox below AdvanceButton. UMG draws later items in front, so the choice buttons end up in front of the advance button and receive clicks first.

Choice buttons get created one at a time on the spot. Preparing a dedicated WBP_ChoiceButton gives them all the same look. Its contents:

ElementContents
ButtonText BlockThe visuals
Variable TargetRow (Name)The row to jump to when this button is pressed
Event Dispatcher OnChosen (Name output)The broadcast that tells the parent it was pressed

WBP_ChoiceButton does nothing but fire OnChosen (carrying TargetRow) from its inner Button's On Clicked. The button itself doesn't know who's listening. This broadcast mechanism is covered in the Event Dispatcher article.


Typewriter Text: Increase the Visible Count

Now for the main event. Revealing text one character at a time is easiest to build not by appending characters one at a time , but by holding the full string and increasing how many characters you show .

Three frames of the typewriter effect. The full string "Hello" is held while the visible count goes 1 → 2 → 3, and Left(full string, visible count) cuts from the start

Add the variables you need for display to WBP_Dialogue.

VariableTypeDefaultRole
CurrentLineS_DialogueLineThe current row's contents (its NextRow and choices are referenced later)
FullStringString(empty)The full text of the current line
VisibleCountInteger0How many characters are shown so far
CharIntervalFloat0.03Seconds per character
TypeTimerTimer HandleThe handle for the looping timer
IsTypingBooleanfalseWhether characters are currently being revealed

Holding a single CurrentLine is the key. Advancing and building choices both ask "what's the current row's NextRow?" and "are there choices?" repeatedly, so remember its contents each time you display a row.

You can't slice a Text value directly, so convert to String once when displaying a line (To String (Text)). Do the slicing in String, and convert back to Text when displaying.

The ShowLine function that displays a row goes like this.

Function: ShowLine (Input: RowName / Name)

  → Get Data Table Row (Data Table: DT_Dialogue, Row Name: RowName)
  → Branch (Condition: Found Row)
      False → Print String ("Row not found: " + RowName) → Return  ← ★always include this
      True ↓
  → Set CurrentLine (= Out Row)    ← "the current row" below reads from this
  → Break S_DialogueLine (Out Row)
  → Set Text (Target: SpeakerText, In Text: Speaker)
  → Set FullString (= To String (Line))
  → Set VisibleCount = 0
  → Set Visibility (Target: ContinueIcon, New Visibility: Hidden)
  → Clear Children (Target: ChoiceBox)
  → Set IsTyping = true
  → Set Timer by Event (Time: CharInterval, Looping: ✔, Event: RevealNextChar)
        → Set TypeTimer (store the returned Timer Handle)

Get Data Table Row doesn't error when the row isn't found . It just returns an empty struct in Out Row. Always branch on Found Row (this pitfall is covered in detail in the Data Table article).

Use Set Timer by Event for the timer. Drag off the red Event pin to create a custom event called RevealNextChar, and check Looping. We pick Set Timer by Event over Set Timer by Function Name because it doesn't require naming a function as a string, so renaming won't break it.

RevealNextChar extends the reveal by one character each time it's called.

Custom Event: RevealNextChar

  → Set VisibleCount = VisibleCount + 1
  → Set Text (Target: BodyText,
              In Text: To Text (Left (FullString, VisibleCount)))
  → Branch (Condition: VisibleCount >= Len (FullString))
      True → FinishTyping

Left (string, count) is the node that returns that many characters from the start . A VisibleCount of 1 gives you the first character, 2 gives you the first two. Increase it by one every 0.03 seconds and characters appear from left to right. If the count exceeds the full length, Left just returns the whole string, so there's no overflow to worry about.

Len (string) returns the character count. Once VisibleCount reaches it, call FinishTyping to stop the timer.

Function: FinishTyping

  → Clear and Invalidate Timer by Handle (Handle: TypeTimer)
  → Set IsTyping = false
  → Set Text (Target: BodyText, In Text: CurrentLine's Line, still as Text)  ← guarantee the full line is shown
  → (If there are choices, build them; otherwise set ContinueIcon to Visible)

Setting the body text once more as Text at the end is there to guarantee the full line ends up displayed . That gets you lines flowing in one character at a time with a "▼" lighting up when they finish.

Sponsored

Make Advancing Two-Stage

Give the advance input (a click or a confirm key) two meanings . The same input does different things depending on whether characters are still being revealed or the line is already fully shown.

Branch diagram of the two-stage advance: mid-reveal shows the full line instantly, fully revealed moves to the next row, and waiting on choices does nothing
Current StatePressing Advance
Characters are still revealingStop early and show the full line at once
Fully revealed (no choices)Move to the next row
Fully revealed (choices present)Do nothing (wait for a choice)

Handle this in AdvanceButton's On Clicked.

AdvanceButton On Clicked

  → Branch (Condition: IsTyping)
      True → FinishTyping                       ← mid-reveal, jump to full display

      False → Branch (Condition: is CurrentLine.Choices non-empty?)
                True  → (do nothing; wait for a choice)
                False → Branch (Condition: is NextRow empty?)
                          True  → EndDialogue    ← end of the conversation
                          False → ShowLine (NextRow)

This two-stage behavior is what determines how text advancing feels. Impatient players get the full line on the first click and move on with the second. Players who want to read slowly wait for the reveal to finish and press once. Neither way of pressing breaks anything , and that's the goal of this shape.

EndDialogue, called when NextRow is empty, tears down the UI and restores control. Its contents come up in "Stop Movement During Conversation" below.


Branching with Choices

For choices, create one button on the spot for each entry in that row's Choices . Fixed buttons placed in advance mean reusing the same slots whether a scene has two choices or three, which can't adapt to the count.

Branch diagram for choices. Two WBP_ChoiceButtons are created from Choices and added to ChoiceBox, and the pressed choice's TargetRow changes the next row

At the end of FinishTyping, call BuildChoices if CurrentLine has choices.

Function: BuildChoices (Input: Choices / array of S_DialogueChoice)

  → Clear Children (Target: ChoiceBox)        ← remove the previous choices
  → For Each Loop (Array: Choices)
      Loop Body →
        Create Widget (Class: WBP_ChoiceButton)
          → Set Choice Text (Array Element's ChoiceText)
          → Set Target Row (Array Element's TargetRow)
          → Bind Event to OnChosen (Target: the button you created)
                Event pin ← Custom Event: HandleChoice (receives TargetRow)
          → Add Child to Vertical Box (Target: ChoiceBox, Content: the button you created)

Step by step: Create Widget builds the actual button, and Add Child to Vertical Box puts it inside ChoiceBox . That pair is the basic combination for dynamically adding parts in UMG. Without Clear Children to delete the old buttons first, the previous choices stay and pile up.

Each button you create registers a listener with Bind Event to OnChosen, so pressing it calls HandleChoice.

Custom Event: HandleChoice (Input: TargetRow / Name)

  → Clear Children (Target: ChoiceBox)   ← clean up the choices
  → ShowLine (TargetRow)                  ← advance to the chosen row

The pressed button carries its own TargetRow in the broadcast, so HandleChoice completes the branch simply by displaying that row. "Yes" goes to the acceptance line, "No" goes to the decline line. A branch isn't a chain of ifs; only the row you jump to differs.


Stop Movement During Conversation

A conversation starting while the player can still walk around ruins the moment. During dialogue, point input at the dialogue UI.

The way to do that is to switch the Player Controller's input mode. Pair the states so you restore them when the conversation ends.

WhenWhat to Call
Starting a conversationSet Input Mode UI OnlySet Show Mouse Cursor (true)
Ending a conversationSet Input Mode Game OnlySet Show Mouse Cursor (false)

With Set Input Mode UI Only, game input is routed to the UI, so movement and jump input never arrive and the character stops . Advancing the dialogue is handled by the AdvanceButton's On Clicked we built earlier. A Button click is a UI event, so it arrives just fine even under UI Only.

There's a common pitfall here: don't try to reuse the key that started the conversation for advancing . If you started the conversation with E, the game input for E is blocked during UI Only, so pressing E again does nothing. Receiving advance input from an on-screen Button instead of a key is the reliable approach.

Wire the start and end of a conversation like this.

StartDialogue (e.g. pressing confirm near an NPC)
  → Create Widget (Class: WBP_Dialogue, Owning Player: Player Controller)
  → Set DialogueRef (hold the instance you created)
  → Set Data Table / Set Start Row (the first row of the conversation)
  → Add to Viewport
  → Get Player Controller
      → Set Input Mode UI Only (In Widget to Focus: DialogueRef)
      → Set Show Mouse Cursor = true
  → DialogueRef → ShowLine (first row)

EndDialogue (inside WBP_Dialogue, when NextRow is empty)
  → Get Owning Player
      → Set Input Mode Game Only
      → Set Show Mouse Cursor = false
  → Remove from Parent

Set Input Mode Game Only alone leaves the mouse cursor on screen, so always pair it with Set Show Mouse Cursor (false) . Restore only one and the cursor lingers after the conversation ends.

Note: This isn't the same as pausing. Set Game Paused also freezes the animations and timers running behind the conversation. If you want the background alive during dialogue, switching input mode like this is the right fit. Cases that genuinely need pausing are covered in the pause menu article.

Sponsored

Hands-On: Talk to an NPC and Branch with Choices

Chatting with villagers in an RPG, event scenes in a visual novel, notes in a horror game, muttered asides in an escape room. Text that flows in when you talk to someone, with choices that change the response, shows up in every genre. Here we build a conversation with a villager that splits based on whether you accept a request.

What It Looks Like Running

Walk up to the villager and press the confirm key, and a conversation starts with text flowing in one character at a time. At the "will you help?" line, two choices appear, and "Yes" leads to a line of thanks while "No" leads to a disappointed line before the conversation ends.

Flow of the hands-on: talking to the villager, typewriter text, the Yes/No choices, and the chosen line ending the conversation, shown as one path

Setup to Reproduce It

Create a new project from the Third Person template and prepare the following.

Two structs (created following the struct article)

StructVariableType
S_DialogueChoiceChoiceTextText
TargetRowName
S_DialogueLineSpeakerText
LineText
NextRowName
ChoicesS_DialogueChoice (Array)

A Data Table called DT_Dialogue (with S_DialogueLine as the Row Structure). Add these four rows. Choices go only on the Ask row, entered directly in the Data Table editor.

Row NameSpeakerLineNextRowChoices
StartVillagerHello there. You must be a traveler.Ask(empty)
AskVillagerIf you have a moment, would you help our village?(empty)Yes→Yes / No→No
YesVillagerThank you! I won't forget this.(empty)(empty)
NoVillagerI see... come back if you change your mind.(empty)(empty)

Two widgets

WidgetContents
WBP_ChoiceButtonButton + Text, variable TargetRow (Name), function SetChoiceText, Event Dispatcher OnChosen (Name output). The Button's On Clicked calls OnChosen
WBP_DialogueThe five parts from "Anatomy of the Dialogue Widget." Variables and functions as described above

BP_NPC (parent class: Actor), which holds the trigger for the conversation.

ElementSettings
Box CollisionThe range you can talk from. Box Extent 150 / 150 / 100
Variable bPlayerInRange (Boolean)Default false
Variable StartRow (Name / Instance Editable)Default Start

Wiring Up the Trigger

In BP_NPC, remember whether the player is in range and start the conversation when the confirm key is pressed inside it.

BP_NPC node graph. Box Overlap toggles bPlayerInRange, and pressing the confirm key while it's true creates WBP_Dialogue and calls ShowLine
BP_NPC

On Component Begin Overlap (Box)
  → Cast To BP_ThirdPersonCharacter
  → Set bPlayerInRange = true
  → Enable Input (Player Controller: Get Player Controller 0)   ← without this, E never arrives

On Component End Overlap (Box)
  → Cast To BP_ThirdPersonCharacter
  → Set bPlayerInRange = false
  → Disable Input (Player Controller: Get Player Controller 0)

Keyboard Event: E (Pressed)
  → Branch (Condition: bPlayerInRange)
      True →
        Create Widget (Class: WBP_Dialogue, Owning Player: Get Player Controller)
        → Set DialogueRef
        → DialogueRef: Set Data Table = DT_Dialogue
        → Add to Viewport
        → Get Player Controller
            → Set Input Mode UI Only (In Widget to Focus: DialogueRef)
            → Set Show Mouse Cursor = true
        → DialogueRef → ShowLine (StartRow)

The part that's easy to miss is Enable Input. An Actor (anything that isn't a Pawn) doesn't receive key input by default. Input only reaches the Pawn the Player Controller is possessing, so placing a Keyboard Event: E on a plain Actor like BP_NPC won't fire on its own. Call Enable Input when the player enters the range to register "route input to this Actor too," and Disable Input when they leave. Now E only works while the player is nearby.

On the WBP_Dialogue side, use the ShowLine / RevealNextChar / FinishTyping / AdvanceButton On Clicked / BuildChoices / HandleChoice / EndDialogue we built above as-is. Only the tail of FinishTyping splits on whether there are choices.

Tail of FinishTyping
  → Branch (Condition: Length of CurrentLine.Choices > 0)
      True  → BuildChoices (CurrentLine.Choices)
      False → Set Visibility (Target: ContinueIcon, New Visibility: Visible)

Verifying

Place BP_NPC in the level, hit Play, walk up, and press E.

  • The conversation starts and "Hello there. You must be a traveler." appears one character at a time , then "▼" lights up when it stops
  • Clicking the screen mid-reveal shows the rest of the line at once
  • Clicking again advances to "If you have a moment, would you help our village?" with "Yes" and "No" buttons appearing below
  • Pressing "Yes" gives "Thank you! I won't forget this." and "No" gives "I see... come back if you change your mind."
  • When the conversation ends, the cursor disappears and you can walk again

Here's how to narrow things down when it doesn't work.

  • The whole line appears at onceLooping isn't checked on Set Timer by Event. It ran once and jumped to the full string
  • Nothing appears, or you see Row not foundStartRow doesn't match the row's name. Row Names are case-sensitive (see checking with Print String)
  • Text runs off the screenAuto Wrap Text is off on BodyText
  • Pressing a choice doesn't advanceWBP_ChoiceButton isn't firing OnChosen, or the parent isn't calling Bind Event
  • Clicking doesn't advance, or choices can't be pressedAdvanceButton is in front of ChoiceBox in the Hierarchy. Move AdvanceButton down (behind)
  • The cursor stays after the conversation, or you can't moveEndDialogue is missing either Set Input Mode Game Only or Set Show Mouse Cursor (false)
  • Walking up and pressing E does nothingEnable Input isn't being called. An Actor's Keyboard Event doesn't fire by default (see "Wiring Up the Trigger" above)
  • Talking does nothing → The Box Collision is too small to enter, or the bPlayerInRange toggles aren't wired up

Now try rewriting the Line on the Data Table's Start row with a much longer sentence and hitting Play. The timer interval hasn't changed, yet longer lines take longer to finish . The reveal time is character count × CharInterval. When you want to change the pacing, adjust CharInterval.

Two points to take away.

  • Typewriter text isn't "adding characters," it's "showing more of them": holding the full text in FullString and cutting with Left (full string, visible count) makes skipping ahead (showing the full line) nothing more than maxing out the visible count. A version that appends characters one at a time needs separate logic just to skip
  • Branching only means "changing which row you read next": don't process the choice contents with if. Just pass the chosen button's TargetRow into ShowLine. However much your conversation branches, the code doesn't grow. Only the Data Table's rows do

Hooking "Yes" up to accepting a quest leads into the quest and objective system article. Styling the lines and adding text-advance sound effects falls under the UMG article.

Sponsored

Bonus: Good to Know for Later

  • If you want repeat conversations to differ, keep the flag outside: to change the starting row based on "first meeting or not," store the fact that you've talked in Game Instance and pick StartRow from that flag. Keep state inside the dialogue data and it resets to a first meeting every time you change levels
  • Left operates on code units: it cuts by character (code unit). Ordinary letters are one unit each, so they reveal cleanly one at a time. Emoji and certain special characters are made of multiple units, so those can briefly render as fragments. It rarely causes real problems in dialogue, but knowing about it saves you from a mysterious glitch later
  • Play the advance sound "the moment a character appears": firing a short SFX inside RevealNextChar produces that classic clicking sound. Every character is grating, so thinning it to once every two or three characters sounds better (for audio, see the sound article)
  • To vary color or position per speaker, switch visuals on Speaker: read SpeakerText's contents and swap the panel color or icon so it's obvious at a glance who's talking. If the conditions multiply, moving speaker info into a Data Asset definition keeps things tidy
  • To let players skip, jump to the last row rather than skipping rows: a "skip all" button is safest when it stops walking NextRow and calls ShowLine on that conversation's terminal row directly. If you can't afford to skip acceptance logic along the way, fast-forward internally to the end instead

Summary

  • Store lines in a Data Table with one row per line . Columns for speaker, body, and next row, with the body held as Text
  • Typewriter text means holding the full text as String and increasing the visible count with a Timer , cutting with Left. Make Set Timer by Event loop
  • Advancing is two-stage . Mid-reveal jumps to the full line, fully revealed moves on. Split on IsTyping
  • Choices are built on the spot with Create WidgetAdd Child to Vertical Box , and branching is just passing the chosen button's TargetRow into ShowLine
  • Stop movement during conversation with Set Input Mode UI Only , and take advance input from a Button's On Clicked . Restore Game Only and cursor-off as a pair at the end

In the game you're building right now, who's the first person a player talks to? Start with just that one conversation, built from a four-row Data Table.