A Dialogue System in UE5: Typewriter Text and Choices for Talking to a Villager

Created: 2026-07-20Last updated: 2026-09-06

Build a conversation with a villager using UE5's Data Table and UMG. Covers managing lines, revealing one character at a time, skipping ahead, branching on choices, and returning control after the conversation.

Talk to a villager and the line appears little by little. Read it, press "Next", and they continue; choose "Yes" or "No" and the reply changes. A conversation screen is UI that not only displays text but advances with the player's reading pace and their answers.

We build a short conversation with a villager offering a request. The lines live in a Data Table, and the Widget that displays them takes the form "read the specified row and move to the next row to read". Add more text later and the same display logic still works.

The finished conversation screen: reading the villager's request and choosing Yes or No

What You'll Learn

  • How to hold dialogue data linked by "the next row to read"
  • Revealing one character at a time and allowing a skip
  • Laying out one button per choice and branching
  • Stopping control during the conversation and restoring it afterwards

What We Build

  • A four-line conversation that starts when you press E near the villager
  • Typewriter text where "Next" shows the full line if it is still revealing
  • Yes and No changing the reply, ending with a return to movement controls

We test with one local player and one NPC in the Third Person template. This assumes you have used UMG element layout, structs, and Blueprint variables and events. Designing for many NPCs is touched on at the end.

Sponsored

A conversation is linked by "the next row to read"

A Data Table is an asset lining up data with the same fields, row by row. Here one row is one line, holding the speaker, the body text, and the next row.

Finish the Start row and go to Ask . Choose "Yes" at Ask and go to the Yes row. Row names act like markers specifying the next page to read.

Holding lines per row, with NextRow and Choices specifying the next row to read

The lines in the diagram are shortened to show the mechanism. In the hands-on we enter the four rows below.

First, decide the shape of one choice

Choose Blueprints → Structure in the Content Browser and create S_DialogueChoice .

VariableTypeContents
ChoiceTextTextThe text shown on the button, such as "Yes"
TargetRowNameThe row to read after choosing it

Next create S_DialogueLine for one line.

VariableTypeContents
SpeakerTextThe speaker's name
LineTextThe line body
NextRowNameThe row "Next" advances to
ChoicesArray of S_DialogueChoiceChoices shown on that row. 0 elements when none

An array holds several things of the same kind. Put two elements into Choices and we can build two buttons later.

Use Text for what is shown on screen and Name for specifying rows . Holding the source text as Text makes translating easier later. Only during the typewriter reveal do we convert to String, which can be sliced.

Enter the villager's four lines

Create a Data Table with S_DialogueLine as its Row Structure and name it DT_Dialogue . We enter data directly in the Data Table editor rather than using CSV.

Row NameSpeakerLineNextRowChoices
StartVillagerHello there. A traveler, I see.Ask0 elements
AskVillagerWould you be willing to help this village?None2 elements
YesVillagerThank you! I owe you one.None0 elements
NoVillagerI see... Come back if you change your mind.None0 elements

Add two elements to Ask's Choices: the first ChoiceText=Yes / TargetRow=Yes and the second ChoiceText=No / TargetRow=No .

None is the Name value meaning no name specified. We use it as the marker for "no next row, so close the conversation". At Ask, though, we want a choice first. NextRow being None alone does not close the screen right after showing the question.

Create the places for lines and buttons

Create WBP_Dialogue with User Widget as its parent and build this hierarchy in the Designer. Start with advancing via a visible "Next" button.

Canvas Panel
└─ Size Box
   └─ Border
      └─ Vertical Box
         ├─ SpeakerText (Text)
         ├─ Size Box
         │  └─ BodyText (Text)
         ├─ ChoiceBox (Vertical Box)
         ├─ ContinueIcon (Text "▼")
         └─ AdvanceButton (Button)
            └─ Text "Next"

Set the outer Size Box's Width Override to 760 . Its Canvas slot uses bottom-center Anchors, Alignment 0.5 / 1 , Position 0 / -32 , and Auto Size on. The Border is white with Padding 24 . Check on a game screen around 1280 x 720.

SpeakerText and BodyText are navy with Font Size 22 . Turn on BodyText's Auto Wrap Text and set its parent Size Box's Min Desired Height to 96 . That reserves space for the body even on short lines and keeps the panel from resizing as text grows.

The Vertical Box's children fill horizontally with vertical Padding 4 . AdvanceButton is blue with white inner Text and Padding 10 . ContinueIcon is right-aligned with initial Visibility Hidden. The "▼" signals that the full line is out and you can continue.

Turn on Is Variable for SpeakerText, BodyText, ChoiceBox, ContinueIcon, and AdvanceButton. Those are the elements the graph changes. Leave ChoiceBox empty for now.

Put a longer test line into BodyText in the Designer and confirm it wraps and fits the panel. Afterwards clear SpeakerText and BodyText's initial text. Runtime text is set by the logic we build next.

The conversation panel layout: speaker, body, choices, the continue marker, and the Next button stacked vertically

The colored frames and dotted lines in the diagram mark element positions. An empty ChoiceBox shrinks at runtime and expands as choices are added.

Make one choice button as a template

Create another User Widget and name it WBP_ChoiceButton . Put ChoiceButton (Button) at the root with ChoiceLabel (Text) as its child, and turn Is Variable on for both. The button is blue with white text at Font Size 22 and Padding 10 . Turn on ChoiceLabel's Auto Wrap Text too.

Prepare the following on this Widget.

What to prepareType / inputsRole
Variable TargetRowNameName, default NoneThe row to read after this button is chosen
Function ConfigureChoiceInText: Text, InTargetRowName: NameReceives the display text and row name
Event Dispatcher OnChosenInput RowName: NameTells the parent which row was chosen

ConfigureChoice runs Set TargetRowName → Set Text from its entry. Pass InTargetRowName to TargetRowName's value, ChoiceLabel to Set Text's Target, and InText to In Text.

ConfigureChoice saves the next row name and sets the choice text on ChoiceLabel

On ChoiceButton's On Clicked, call Call OnChosen , passing Get TargetRowName to RowName.

Clicking a choice announces the TargetRowName the button remembers via OnChosen

An Event Dispatcher is the mechanism telling parties that registered to listen. This button says "Yes was chosen" and the parent conversation screen advances to the Yes row. See the Event Dispatcher article for the mechanism in detail.

Sponsored

The mechanism for revealing one character at a time

If the full line is "Hello there", we first show "H", then "He", widening the range shown from the start. The full text never changes; we increase how many characters are shown .

From the full text, Left takes the first 1, 3, and 5 characters for the typewriter effect

Left takes a specified number of characters from the start of a string. We increase the shown count by one and put the result into BodyText.

A Timer calls work at a regular interval. Here it updates the display every 0.03 seconds. For normal text, more characters means longer until the whole line is out.

Create these variables on WBP_Dialogue.

VariableTypeDefaultRole
CurrentLineS_DialogueLineThe struct's defaultInformation about the row being displayed
FullStringStringEmptyThe full text used for slicing
VisibleCountInteger0How many characters from the start are shown
CharIntervalFloat0.03Seconds until the next update
TypeTimerTimer HandleDefaultThe value identifying the timer to stop later
IsTypingBooleanfalsetrue while the reveal is in progress

IsTyping changes what "Next" means. When true it shows the full text; when false it checks for choices and decides whether to advance.

ShowLine , RevealNextChar , FinishTyping , BuildChoices , HandleChoice , and EndDialogue below are all created as Custom Events in WBP_Dialogue's Event Graph . ShowLine and HandleChoice take a Name input RowName; the rest take no arguments.

Events called from timers and Dispatchers go in the same Event Graph. Do not put Custom Events in a regular Function's editing view.

Read the row to display and start the timer

ShowLine clears the previous display

ShowLine is the call saying "display the row with this name". It first clears the previous timer and display.

OrderNodeWhat to specify
1Clear and Invalidate Timer by HandleHandle = TypeTimer
2Set IsTypingfalse
3BodyText's Set TextIn Text empty
4ContinueIcon's Set VisibilityHidden
5ChoiceBox's Clear ChildrenRemoves old choices
6AdvanceButton's Set Is Enabledtrue

Setting the character count to 0 alone does not clear the previous line from the screen. Empty BodyText too. Stopping the previous timer prevents old updates from mixing in after the new row starts.

Next, use a Branch to compare RowName against the Name None . True goes to EndDialogue.

On False, call Get Data Table Row . Choose DT_Dialogue directly for Data Table and pass ShowLine's RowName to Row Name.

That node has two white exec outputs, Row Found and Row Not Found . You do not need to look for a Boolean telling you whether the row was found.

  • Row Found: continue to "set the row's contents"
  • Row Not Found: Print String the RowName and go to EndDialogue

Leaving the conversation screen up after reading a missing row traps the player away from their controls. Continue to the close logic on failure too. Get Data Table Row official reference

Storing Out Row into CurrentLine only when the row was found, and ending the conversation when it was not

Set the row's contents

From Row Found, wire exec in this order.

  1. Pass Out Row to Set CurrentLine 's value.
  2. Split CurrentLine with Break S_DialogueLine and pass Speaker to SpeakerText's Set Text.
  3. Convert Line with To String (Text) and pass it to Set FullString.
  4. Set VisibleCount to 0 and IsTyping to true .

Break extracts the fields collected in a struct. Here we use speaker and body, with NextRow and Choices used later for advancing.

Use a Branch to check whether FullString's Len is 0. Len is the character count. On an empty line, True goes to FinishTyping and puts it straight into the ready-to-advance state.

On False, call Set Timer by Event with Time set to CharInterval and Looping on. Connect the red Event pin to the red output of the RevealNextChar we prepared. Pass Return Value to Set TypeTimer and wire white exec from the Timer to that Set.

The red connection registers "call RevealNextChar when the time comes". RevealNextChar's white exec output leads to the character-update logic.

The red connection registering RevealNextChar on the timer's Event, called repeatedly every 0.03 seconds

The diagram shows the event registration part. Connect CharInterval to Time and continue from the Timer's white exec output and Return Value into Set TypeTimer. Remembering the returned value lets you stop this specific timer on full reveal or on ending.

RevealNextChar widens the shown range

  1. Add integer 1 to VisibleCount and store it back with Set VisibleCount.
  2. Pass FullString to Left 's Source String and the updated VisibleCount to Count.
  3. Convert Left's Return Value to Text with To Text (String) and pass it to BodyText's Set Text.
  4. Use a Branch to check whether VisibleCount is at least Len(FullString), and call FinishTyping on True. False simply ends.

The Timer calls the next update. This logic does not call itself again.

RevealNextChar increases VisibleCount by 1 and continues to the body update

After storing the increased count, slice from the start with that new value. Connect BodyText, the display target, to Set Text's Target.

Passing FullString and the updated VisibleCount to Left and displaying the extracted characters in BodyText

Separate full reveal from advancing

Three states: full reveal while revealing, the next row after full reveal, and waiting for an answer with choices

FinishTyping standardizes the finished-reading state

Pass IsTyping to a Branch at FinishTyping's entry and continue only on True. False does nothing. That way, being called from both the timer and the button never builds the same row's choices twice.

From True, wire this order.

  1. Stop TypeTimer with Clear and Invalidate Timer by Handle .
  2. Set IsTyping to false.
  3. Pass CurrentLine's Line, still as Text, to BodyText's Set Text.
  4. Take CurrentLine's Choices and Branch on whether the array's Length is greater than 0.

On the True side with choices, set AdvanceButton's Set Is Enabled to false and call BuildChoices. On False, set ContinueIcon to Visible.

Now, whether you press "Next" mid-reveal or wait for the end, you arrive at the same state: the full text shown, with either choices or the "▼" visible .

AdvanceButton acts based on the current state

From AdvanceButton's On Clicked, pass IsTyping to a Branch.

StateAction
IsTyping = trueCall FinishTyping and show the current line in full
IsTyping = false and Choices Length is 0Call ShowLine with RowName set to CurrentLine's NextRow
IsTyping = false and Choices Length is 1+Do nothing. Wait for a choice

On the false side, use another Branch to check whether Choices' Length is 0. If NextRow is None, ShowLine's entry leads to EndDialogue.

Read slowly and you press once after the text finishes flowing. Want to read ahead and the first press shows the full text, the second moves to the next line. Not skipping to an unread next line from one mid-reveal press is the point of this split.

Lay out one button per choice

In BuildChoices, pass CurrentLine's Choices to a For Each Loop . For Each Loop takes array elements one at a time and applies the same logic.

Using each choice's TargetRow so "Yes" reads the Yes row and "No" reads the No row

Wire the following in order from Loop Body.

OrderNodeWhat to specify
1Create WidgetClass = WBP_ChoiceButton, Owning Player = Get Owning Player
2Configure ChoiceTarget = the created Widget. InText = Array Element's ChoiceText, InTargetRowName = TargetRow
3Bind Event to OnChosenTarget = the same Widget. Event = HandleChoice
4Add Child to Vertical BoxTarget = ChoiceBox, Content = the same Widget

Split Array Element with Break S_DialogueChoice . Create Widget's Return Value is used in three places: Configure Choice, Bind, and Add. Creating it does not place it on screen, so add it to ChoiceBox at the end.

Creating a choice Widget and passing the text and row name from the array to ConfigureChoice

Connect HandleChoice to Bind's red Event pin and confirm the RowName input is a Name on both sides. Wire HandleChoice's white exec output to ShowLine, passing RowName straight through.

The red connection registering HandleChoice as the receiver of a choice's OnChosen

The red wire registers "who to call when the button is chosen". Running Bind does not run HandleChoice. When a choice is actually made, the following white wire leads to ShowLine.

Passing the row name received in HandleChoice straight to ShowLine

The logic still building the button continues from Bind's white exec output into Add Child to Vertical Box. Target is the container ChoiceBox and Content is the choice Widget going inside.

Specifying ChoiceBox for Add Child to Vertical Box's Target and the created choice for Content

Old buttons are cleared at the start of ShowLine. BuildChoices is called once per row from FinishTyping's True side.

Choose "No" and the button announces the name No, and HandleChoice calls ShowLine(No). There is no need to add branches comparing the text, as in "if it said No, then...".

Sponsored

Talk to the NPC and restore control afterwards

With the display logic done, connect what starts the conversation and what cleans up afterwards.

Three scenes: talking to the NPC, movement stopped during the conversation, and control restored afterwards

Place the NPC and the range you can talk in

Create BP_NPC with Actor as its parent and add a marker Static Mesh and a Box Collision. Name the Box TalkRange with Box Extent 150 / 150 / 100 .

TalkRange is not a wall pushing the player back; it is the range for knowing about entry and exit. Any shape such as a Cube is fine for the marker Mesh.

Set TalkRange's Collision Enabled to Query Only, Ignore on all channels except Overlap for Pawn, and Generate Overlap Events on. Turn on the player Capsule's Generate Overlap Events too. The marker Mesh can be No Collision for this exercise.

Create these variables on BP_NPC.

VariableTypeDefault
bPlayerInRangeBooleanfalse
StartRowNameStart
DialogueRefWBP_Dialogue Object ReferenceNone

DialogueRef is the reference for naming the created conversation screen later. The default None means no screen is remembered yet. We store it when starting a conversation and clear it when it ends.

Turn on Instance Editable for StartRow so the starting row can be changed from a placed NPC's Details.

On TalkRange's Begin Overlap, check these two with Object == (the node comparing whether they are the same) and combine them with AND Boolean into a Branch's Condition.

  • Other Actor equals Get Player Character (Player Index = 0)
  • Other Comp equals that Character's Get Capsule Component

AND checks whether both are true. Using only the player's Capsule avoids treating another component, such as the Mesh, entering or leaving as leaving the range.

Only on the Branch's True, set bPlayerInRange to true and call Enable Input . Target is Self and Player Controller is Get Player Controller (0).

End Overlap makes the same two comparisons and, on True only, sets bPlayerInRange to false and calls Disable Input with the same Player Controller.

Enable Input registers this NPC to receive key input. Placing an E key event on a plain Actor does not deliver input without it.

Create the conversation screen and receive the end notification

Add an argument-less Event Dispatcher OnDialogueEnded to WBP_Dialogue and compile. It tells the NPC that opened the screen that the conversation ended.

Place an E Keyboard Event on BP_NPC and pass bPlayerInRange from Pressed into a Branch. From True, check DialogueRef with the exec-pin version of Is Valid.

Is Valid checks whether the reference exists and is usable. Here we use it to check whether the conversation screen was already created.

  • Is Valid: a conversation screen already exists, so do nothing
  • Is Not Valid: Create Widget for WBP_Dialogue with Owning Player = Get Player Controller (0)
Doing nothing when DialogueRef is valid, and creating a conversation screen only when it is not

After Create Widget, pass Return Value to Set DialogueRef. Then call Bind Event to OnDialogueEnded with DialogueRef as Target.

Registering the NPC's HandleDialogueEnded on the created screen's OnDialogueEnded

From Bind's Event, create an argument-less Custom Event HandleDialogueEnded in BP_NPC's Event Graph. That event places a Set DialogueRef, returning the value to an unconnected None. It is the logic that lets you talk again after closing the screen .

Returning DialogueRef to None when HandleDialogueEnded fires

Stop movement and route input to the conversation screen

From Bind's white exec output, wire this order.

OrderNodeWhat to specify
1Add to ViewportTarget = DialogueRef
2Set Ignore Move InputTarget = Get Player Controller (0), New Move Input = true
3Stop Movement ImmediatelyTarget = the Character Movement from Get Player Character (0)
4Set Input Mode UI OnlyPlayer Controller = Get Player Controller (0), Focus empty, Do Not Lock, Flush Input = true
5Set Show Mouse CursorTarget = the same Player Controller, true
6Show LineTarget = DialogueRef, RowName = StartRow

UI Only routes input to the UI. Velocity the character already has does not necessarily become 0 from that alone. We add ignoring movement input and stop current movement with the Movement Component's Stop Movement Immediately.

During the conversation, the controls are the screen's "Next" and clicking choices. E starts the conversation and is not used for advancing. We do not pause the game, so the typewriter Timer keeps running.

EndDialogue restores what was stopped

Build WBP_Dialogue's EndDialogue in this order. Use Get Owning Player when fetching the Player Controller here: the screen's owner specified at Create Widget.

OrderNodeWhat to specify
1Clear and Invalidate Timer by HandleHandle = TypeTimer
2Set IsTypingfalse
3Set Ignore Move InputTarget = Get Owning Player, New Move Input = false
4Set Input Mode Game OnlyPlayer Controller = Get Owning Player, Flush Input = true
5Set Show Mouse CursorTarget = Get Owning Player, false
6Remove from ParentTarget = Self
7Call OnDialogueEndedNo arguments

Remove from Parent detaches the conversation screen, and OnDialogueEnded returns the NPC's DialogueRef to empty. Stopping the timer, restoring input, and cleaning up the screen are gathered into the same ending logic.

We call Set Ignore Move Input with false once for the one true earlier. Preventing duplicate opens is not only about the screen; it also prevents forgetting to restore states like this.

Run it all the way through

Compile, save, and place one BP_NPC in a Third Person level. Approach the NPC on the floor and press E .

First confirm on the Start line that characters appear little by little and "▼" appears at the end . Getting that far means row loading, the Timer, and Text display are all connected.

Pressing mid-flow shows the full text, pressing again reaches the question, choosing gives the reply, and the screen closes at the end

Next, try the same conversation in this order.

ActionExpected result
Press "Next" once while text is flowingThe same line shows in full. It does not skip to the next row
Press again after the full revealIt advances to the Ask question, with Yes and No appearing after the reveal
Choose "Yes"The Yes line "Thank you! I owe you one." appears
Read the last line and press "Next"The screen and cursor disappear and you can move again
Talk to the same NPC again and choose "No"It branches to the No reply. No old choices remain
Talk to the NPC while walkingYou do not keep walking during the conversation and resume afterwards

Now change only DT_Dialogue's Start row to a long sentence. The display Blueprint stays the same while the line and its reveal time change. Setting CharInterval to 0.08 makes the same sentence read more slowly. Keep the interval greater than 0.

When it does not work

SymptomWhere to check
Only one character appears and it stopsWhether Set Timer by Event's Looping is on
No characters appearWhether the Timer's Event connects to RevealNextChar, whether CharInterval is 0 or less
Only a row name is logged and it closesThe DT_Dialogue assignment and the spelling of StartRow and TargetRow, including stray characters
The previous line flashesWhether BodyText is emptied at the start of ShowLine
Choices keep accumulatingShowLine's Clear Children and the IsTyping branch at FinishTyping's entry
Pressing a choice does nothingThe TargetRowName assignment in ConfigureChoice, the OnChosen Call, the parent's Bind
Pressing E does not start itTalkRange's Overlap, the Other Actor comparison, Enable Input
You cannot talk again after one conversationThe OnDialogueEnded Bind and the logic returning DialogueRef to None
You cannot move afterwards, or the cursor staysThe three restores in EndDialogue: movement input, Game Only, and the cursor

The Name type used for Row Name compares case-insensitively. Do not assume a Start versus start difference is the cause; check for a nonexistent row or the wrong Data Table.

Bonus: extending it to your own game

Adding more NPCs. This exercise routes input to one NPC. In a game with several talkable characters nearby, have the player decide which one to talk to and ask that one to start, and it stays organized.

Making choices change the game. Our "Yes" only changes the reply. To accept a quest, notify a party separate from the display logic. The quest and objective system is the continuation.

Changing the second conversation. Store the fact that you talked in something like Game Instance or SaveGame and choose StartRow from it. Use the Data Table as the line definitions and remember per-player progress separately.

Translation and special characters. We hold source text as Text but slice Strings during the reveal. Emoji and combining characters can consist of several units per visible character. To keep the reveal clean, check with the actual text containing those characters and change how you slice if needed.

Editing in CSV or JSON. External files become an option as conversations grow. Since re-importing replaces the data, avoid workflows where Choices added in the editor and the import source are updated separately. Include the choices in the source data and verify the branches after importing.

Skipping the whole conversation. Whether jumping to the last line is enough depends on whether acceptance or rewards happen along the way. Separate skippable presentation from required progression logic before implementing it.

Summary

Conversation content lives in a Data Table and the Widget displays the specified row. A Timer increases the characters shown, a mid-reveal "Next" shows the full text, and a "Next" after reading advances to the next row.

Choices work the same way: the chosen button's TargetRowName is passed to ShowLine. Start by talking to the same villager twice and going through both Yes and No. Returning to movement at the end means one full round of read, choose, and back to play.

Reference: Using Data Tables, Set Timer by Event, Comparing Names, Stop Movement Immediately

Unreal Engine Notes in this section98