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.

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
Enear 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.
- A conversation is linked by "the next row to read"
- Create the places for lines and buttons
- The mechanism for revealing one character at a time
- Read the row to display and start the timer
- Separate full reveal from advancing
- Lay out one button per choice
- Talk to the NPC and restore control afterwards
- Run it all the way through
- Bonus: extending it to your own game
- Summary
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.

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 .
| Variable | Type | Contents |
|---|---|---|
| ChoiceText | Text | The text shown on the button, such as "Yes" |
| TargetRow | Name | The row to read after choosing it |
Next create S_DialogueLine for one line.
| Variable | Type | Contents |
|---|---|---|
| Speaker | Text | The speaker's name |
| Line | Text | The line body |
| NextRow | Name | The row "Next" advances to |
| Choices | Array of S_DialogueChoice | Choices 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 Name | Speaker | Line | NextRow | Choices |
|---|---|---|---|---|
| Start | Villager | Hello there. A traveler, I see. | Ask | 0 elements |
| Ask | Villager | Would you be willing to help this village? | None | 2 elements |
| Yes | Villager | Thank you! I owe you one. | None | 0 elements |
| No | Villager | I see... Come back if you change your mind. | None | 0 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 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 prepare | Type / inputs | Role |
|---|---|---|
| Variable TargetRowName | Name, default None | The row to read after this button is chosen |
| Function ConfigureChoice | InText: Text, InTargetRowName: Name | Receives the display text and row name |
| Event Dispatcher OnChosen | Input RowName: Name | Tells 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.

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

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.
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 .

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.
| Variable | Type | Default | Role |
|---|---|---|---|
| CurrentLine | S_DialogueLine | The struct's default | Information about the row being displayed |
| FullString | String | Empty | The full text used for slicing |
| VisibleCount | Integer | 0 | How many characters from the start are shown |
| CharInterval | Float | 0.03 | Seconds until the next update |
| TypeTimer | Timer Handle | Default | The value identifying the timer to stop later |
| IsTyping | Boolean | false | true 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.
| Order | Node | What to specify |
|---|---|---|
| 1 | Clear and Invalidate Timer by Handle | Handle = TypeTimer |
| 2 | Set IsTyping | false |
| 3 | BodyText's Set Text | In Text empty |
| 4 | ContinueIcon's Set Visibility | Hidden |
| 5 | ChoiceBox's Clear Children | Removes old choices |
| 6 | AdvanceButton's Set Is Enabled | true |
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

Set the row's contents
From Row Found, wire exec in this order.
- Pass Out Row to
Set CurrentLine's value. - Split CurrentLine with
Break S_DialogueLineand pass Speaker to SpeakerText's Set Text. - Convert Line with
To String (Text)and pass it to Set FullString. - Set VisibleCount to
0and IsTyping totrue.
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 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
- Add integer
1to VisibleCount and store it back with Set VisibleCount. - Pass FullString to
Left's Source String and the updated VisibleCount to Count. - Convert Left's Return Value to Text with
To Text (String)and pass it to BodyText's Set Text. - 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.

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

Separate full reveal from advancing

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.
- Stop TypeTimer with
Clear and Invalidate Timer by Handle. - Set IsTyping to false.
- Pass CurrentLine's Line, still as Text, to BodyText's Set Text.
- 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.
| State | Action |
|---|---|
| IsTyping = true | Call FinishTyping and show the current line in full |
| IsTyping = false and Choices Length is 0 | Call 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.

Wire the following in order from Loop Body.
| Order | Node | What to specify |
|---|---|---|
| 1 | Create Widget | Class = WBP_ChoiceButton, Owning Player = Get Owning Player |
| 2 | Configure Choice | Target = the created Widget. InText = Array Element's ChoiceText, InTargetRowName = TargetRow |
| 3 | Bind Event to OnChosen | Target = the same Widget. Event = HandleChoice |
| 4 | Add Child to Vertical Box | Target = 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.

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 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.

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.

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...".
Talk to the NPC and restore control afterwards
With the display logic done, connect what starts the conversation and what cleans up 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.
| Variable | Type | Default |
|---|---|---|
| bPlayerInRange | Boolean | false |
| StartRow | Name | Start |
| DialogueRef | WBP_Dialogue Object Reference | None |
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)

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

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 .

Stop movement and route input to the conversation screen
From Bind's white exec output, wire this order.
| Order | Node | What to specify |
|---|---|---|
| 1 | Add to Viewport | Target = DialogueRef |
| 2 | Set Ignore Move Input | Target = Get Player Controller (0), New Move Input = true |
| 3 | Stop Movement Immediately | Target = the Character Movement from Get Player Character (0) |
| 4 | Set Input Mode UI Only | Player Controller = Get Player Controller (0), Focus empty, Do Not Lock, Flush Input = true |
| 5 | Set Show Mouse Cursor | Target = the same Player Controller, true |
| 6 | Show Line | Target = 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.
| Order | Node | What to specify |
|---|---|---|
| 1 | Clear and Invalidate Timer by Handle | Handle = TypeTimer |
| 2 | Set IsTyping | false |
| 3 | Set Ignore Move Input | Target = Get Owning Player, New Move Input = false |
| 4 | Set Input Mode Game Only | Player Controller = Get Owning Player, Flush Input = true |
| 5 | Set Show Mouse Cursor | Target = Get Owning Player, false |
| 6 | Remove from Parent | Target = Self |
| 7 | Call OnDialogueEnded | No 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.

Next, try the same conversation in this order.
| Action | Expected result |
|---|---|
| Press "Next" once while text is flowing | The same line shows in full. It does not skip to the next row |
| Press again after the full reveal | It 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 walking | You 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
| Symptom | Where to check |
|---|---|
| Only one character appears and it stops | Whether Set Timer by Event's Looping is on |
| No characters appear | Whether the Timer's Event connects to RevealNextChar, whether CharInterval is 0 or less |
| Only a row name is logged and it closes | The DT_Dialogue assignment and the spelling of StartRow and TargetRow, including stray characters |
| The previous line flashes | Whether BodyText is emptied at the start of ShowLine |
| Choices keep accumulating | ShowLine's Clear Children and the IsTyping branch at FinishTyping's entry |
| Pressing a choice does nothing | The TargetRowName assignment in ConfigureChoice, the OnChosen Call, the parent's Bind |
| Pressing E does not start it | TalkRange's Overlap, the Other Actor comparison, Enable Input |
| You cannot talk again after one conversation | The OnDialogueEnded Bind and the logic returning DialogueRef to None |
| You cannot move afterwards, or the cursor stays | The 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