"Kill 3 slimes." "Go to the shrine outside the village." "Gather herbs and report back." Game objectives are built from small conditions like these stacked together. But once you actually build one, the pieces scatter: where to remember how many you've killed, how to tell the objective a kill happened, where to decide the goal is met.
Quests come together by combining mechanisms from earlier articles: Data Asset, Enum, Game Instance, Event Dispatcher. This article starts with the design that links them into one flow and builds up to a minimal quest whose count advances on every kill.
What You'll Learn
- A quest is made of three parts: definition, progress state, and notification
- Narrow states to four with an Enum (not started / in progress / reached goal / completed)
- Keep progress in the Game Instance so it survives level changes
- Progress is driven by the enemy-death event . The quest only listens
- Separating reaching the goal from completing it blocks both double completion and premature completion
- Hands-on: build "kill 3 slimes" so the count advances with every kill
A Quest Is Made of Three Parts
Before building anything, split a quest into three parts . Blend them together and it becomes unmanageable later.

| Part | What it holds | When it changes | Where it lives |
|---|---|---|---|
| Definition | The objective's content (what, how many, what reward) | Only during development tuning | Data Asset |
| Progress state | How many so far, which state it's in | Constantly during play | A variable on the Game Instance |
| Notification | Announces that something changed | Every time it advances | Event Dispatcher |
This is the same "separate the definition from the owned state" idea that appeared in the inventory article. Putting what doesn't change (the definition) and what does (the progress state) in different places is the starting point.
Start with the definition. Create BPDA_QuestDefinition following the Data Asset steps (parent class Primary Data Asset ) and lay out these variables. Turn Instance Editable on for all of them.
| Variable name | Type | Example |
|---|---|---|
QuestID | Name | SlimeHunt |
Title | Text | Slime Extermination |
Description | Text | Kill 3 slimes |
TargetEnemyID | Name | Slime |
RequiredCount | Integer | 3 |
RewardGold | Integer | 100 |
That gives you "kill 3 slimes for 100 gold" as a single asset, DA_Quest_SlimeHunt . Adding quests becomes the act of adding one more asset.
The progress state is held separately from the definition. Because it changes at runtime, it's represented as a Struct rather than a Data Asset. Create S_QuestProgress .
| Variable name | Type | Contents |
|---|---|---|
Definition | BPDA_QuestDefinition (object reference) | Which quest this is |
State | E_QuestState (created in the next section) | Which stage it's at |
CurrentCount | Integer | How many so far |
The progress state holds only which definition it points at (a reference), the current state, and the current count . The target count (3) and the reward (100) are in the definition, so they aren't copied into the progress state. Copy them and rebalancing 3 into 5 leaves already-accepted quests stuck at 3.
Narrow States to Four with an Enum
A quest moves through several stages in order. Store those stages as bool values and you end up hand-managing combinations like "accepted but not achieved" and "achieved but not reported", which falls apart. Only one stage can be true at a time, so an Enum is a perfect fit.
Create E_QuestState with four entries.

| State | Meaning | Condition to move on |
|---|---|---|
| NotStarted | Not accepted yet | Accept it |
| InProgress | Accepted and working toward the goal | The count reaches the target |
| ReachedGoal | The goal is met but not reported yet | Report it |
| Completed | Reported and the reward received | (this is the end) |
You might wonder why reaching the goal and completing are separate. It's to model the familiar RPG flow where "the killing is done, but the quest isn't over until you report to the client and collect the reward". The reward doesn't arrive the instant the third slime dies; the quest pauses at reached-goal and advances to completed on report . That extra stage is what makes the "block double completion" logic in a later section trivial to write.
The key points of the diagram are that states move one way, and that you can only advance to completed from reached-goal . No jumping straight from in-progress to completed, and no completing an already-completed quest again. One check stops both.
Keep Progress in the Game Instance
The next fork is where to put the progress state. Store it on the player Character and progress resets to 0 the moment you move to another level , because quests span multiple maps: accept in town, advance in the dungeon, report back in town.

The only container that survives level changes is the Game Instance. That's where all accepted quests live.
Prepare the following on BP_GameInstance (parent class: GameInstance).
| Kind | Name | Contents |
|---|---|---|
| Variable | ActiveQuests | Map with QuestID (Name) as key and S_QuestProgress as value |
| Variable | TotalGold | Integer. Where rewards land. Default 0 |
| Event Dispatcher | OnQuestUpdated | Has one Name output. Fires when a quest moves |
| Event Dispatcher | OnEnemyKilled | Has one Name output. Fires when an enemy is killed |
Once created, set Project Settings > Project > Maps & Modes > Game Instance Class to BP_GameInstance . Forget this and your class is never used, so everything silently does nothing (→ the Game Instance article).
Progress lives in a Map<QuestID, S_QuestProgress> because "what's the progress on this quest?" is then a single Find . Start with the function that accepts a quest.
Function: AcceptQuest (input: Definition / BPDA_QuestDefinition)
→ Branch (Condition: Contains (Target: ActiveQuests, Key: Definition → QuestID))
True → Return <- already accepted, don't accept twice
→ Make S_QuestProgress
Definition = Definition
State = InProgress
CurrentCount = 0
→ Add (Target: ActiveQuests, Key: Definition → QuestID, Value: the progress you built)
→ Call OnQuestUpdated (QuestID: Definition → QuestID)
Checking Contains first prevents the accident of accepting the same quest twice and resetting its progress.
Note:
Definitionis a reference to a Data Asset, and that stays valid across levels . What you must not put in the Game Instance are references to Actors and Widgets, which becomeNonein the next level. Data Assets don't belong to a level, so the reference never breaks. This distinction is covered in "what belongs there and what doesn't" in the Game Instance article.
Progress Is Driven by Events
Here's the heart of the design. When advancing the count, don't have the enemy reach out to the quest .
The obvious approach is to write "on death, find the quest manager and increment the slime-hunt quest's count" into the slime's Blueprint. But then every new quest means editing the enemy. Add a goblin hunt and the goblin needs the same logic. Add an achievement system and it needs it too.
Instead, the enemy only broadcasts that it was killed . Who listens and what they do is up to the listeners. This loose-coupling idea, and the full create/bind/call procedure for Dispatchers, is in the Event Dispatcher article. Here we'll write only what's needed to hook it to a quest.

The enemy fires OnEnemyKilled at the moment it's killed.
BP_Slime (on being killed)
→ Get Game Instance → Cast To BP_GameInstance
→ Call OnEnemyKilled (EnemyID: "Slime")
→ Destroy Actor
The Game Instance binds its own handler to its OnEnemyKilled in Event Init . That way, HandleEnemyKilled runs whenever an enemy broadcasts.
BP_GameInstance
Event Init
→ Bind Event to OnEnemyKilled (Target: Self)
Event pin ← Custom Event: HandleEnemyKilled (receives EnemyID)
HandleEnemyKilled looks at the accepted quests and increments the count if that quest was hunting this enemy.

Custom Event: HandleEnemyKilled (input: EnemyID / Name)
→ Keys (Target: ActiveQuests) → For Each Loop
Loop Body (treat Array Element as QuestID) →
Find (Target: ActiveQuests, Key: QuestID) → Progress
→ Branch (Condition:
Progress.State == InProgress
AND
Progress.Definition → TargetEnemyID == EnemyID )
True →
Set Members in S_QuestProgress (Struct: Progress)
CurrentCount = Progress.CurrentCount + 1 <- increment
→ Branch (Condition: new CurrentCount >= Progress.Definition → RequiredCount)
True → Set Members in S_QuestProgress (State = ReachedGoal) <- target hit, move to reached goal
→ Add (Target: ActiveQuests, Key: QuestID, Value: Progress) <- write it back
→ Call OnQuestUpdated (QuestID: QuestID)
Two things worth noting.
First, always include State == InProgress in the check. Without it, quests that are already at reached-goal or completed keep counting up every time you kill the target enemy. Narrow it to "count only what's in progress".
Second, don't forget the write-back. The struct you pulled with Find is a copy . Incrementing it with Set Members only edits that copy; the Map's contents don't change. Only the final Add back into the same key updates the Map (this value-type behavior is covered in the struct article and the containers article). Skip the write-back and the count sits at 0/3 forever.
Separate Reaching the Goal from Completing
Kill three and the state becomes ReachedGoal , but no reward has arrived yet. Only reporting advances it to Completed and hands over the reward. The CompleteQuest function that receives that report is the gate that blocks double completion.

Function: CompleteQuest (input: QuestID / Name → output: Success / Boolean)
→ Find (Target: ActiveQuests, Key: QuestID) → Progress
→ Branch (Condition: Progress.State == ReachedGoal)
False → Set Success = false → Return <- stops here whether unfinished or already completed
True ↓
→ Set Members in S_QuestProgress (Struct: Progress)
State = Completed
→ Add (Target: ActiveQuests, Key: QuestID, Value: Progress) <- write it back
→ Set Total Gold = Total Gold + Progress.Definition → RewardGold
→ Call OnQuestUpdated (QuestID: QuestID)
→ Set Success = true
The key is letting completion through only when the state is reached-goal . That single check stops two kinds of accident at once.
- Premature completion (reported while in progress): the state is
InProgress, so it exits throughFalseand does nothing - Double completion (reported again after completing): the state is
Completed, so it also exits throughFalse
You never need a separate bool flag remembering "did we hand out the reward". The state itself says whether it's over, so one state check is enough. That's the payoff from separating reached-goal from completed.
Confining the reward addition inside CompleteQuest matters too. Pinning down one place where TotalGold is modified means a later change like "double rewards during a bonus event" stays inside this function (the same idea as the hands-on in the Game Instance article).
Getting Changes to the UI
When progress changes, update the on-screen tracker (a display like Slime 1/3 ). Rebuilding it every frame on Event Tick keeps updating even when nothing changed, which gets heavy.
The OnQuestUpdated you prepared is the broadcast for this. It reaches the UI only at the moment a quest moves . The mechanism is identical to OnInventoryChanged in the inventory article, so here are just the essentials.
WBP_QuestTracker
Event Construct
→ Get Game Instance → Cast To BP_GameInstance → Promote to variable (GIRef)
→ Bind Event to OnQuestUpdated (Target: GIRef)
Event pin ← Custom Event: RefreshTracker
→ RefreshTracker <- call it once yourself the first time
Custom Event: RefreshTracker
→ Find (Target: GIRef → ActiveQuests, Key: the QuestID being tracked) → Progress
→ Switch on E_QuestState (Selection: Progress.State)
InProgress → Set Text ("Slime " + CurrentCount + " / " + RequiredCount)
ReachedGoal → Set Text ("Hunt complete! Go report it")
Completed → Set Visibility (Collapsed) <- hide the tracker
Event Destruct
→ Unbind Event from OnQuestUpdated (Target: GIRef)
Two notes. Call RefreshTracker once yourself in Construct , because the Dispatcher only fires "when something changes". Quests already accepted when the UI opens won't display unless you go read them. And always Unbind in Destruct . The reason is in "binding always comes with unbinding" in the Event Dispatcher article.
Switch on E_QuestState is used to split the display because, as the Enum article explains, every possible state appears as a pin so a missing case is visible at a glance. The tracker's actual appearance belongs to the UMG article.
Hands-On: Closing the "Kill 3 Slimes" Loop
RPG bounty requests, survival gathering quotas, adventure-game collectibles, tower defense's "clear by defeating N". The loop of counting toward an objective, reaching it, reporting, and completing takes the same shape in every genre. Here we'll skip polished visuals and close the loop while watching the counts through Print String.
What it looks like running
Accepting the quest sets the tracker to 0/3 , and each slime you kill advances it 1/3 → 2/3 → 3/3 . On the third it changes to "go report it", and reporting hands over 100 gold and completes it. Reporting before the goal, or reporting again after completing, does nothing.

Setup to follow along
Create a new project from the Third Person template and prepare the following.
Enum E_QuestState (entries: NotStarted / InProgress / ReachedGoal / Completed , in this order)
Struct S_QuestProgress (variables: Definition (BPDA_QuestDefinition reference) / State (E_QuestState) / CurrentCount (Integer / 0))
Data Asset type BPDA_QuestDefinition (the Data Asset steps; parent is Primary Data Asset; all variables Instance Editable)
Quest data DA_Quest_SlimeHunt
| Variable | Value |
|---|---|
QuestID | SlimeHunt |
TargetEnemyID | Slime |
RequiredCount | 3 |
RewardGold | 100 |
BP_GameInstance (parent: GameInstance)
| Kind | Name | Type / default |
|---|---|---|
| Variable | ActiveQuests | Map (Name → S_QuestProgress) |
| Variable | TotalGold | Integer / 0 |
| Dispatcher | OnQuestUpdated | Name output |
| Dispatcher | OnEnemyKilled | Name output |
The functions AcceptQuest / HandleEnemyKilled / CompleteQuest are as written above. Bind OnEnemyKilled to HandleEnemyKilled in Event Init . After creating it, set Project Settings > Maps & Modes > Game Instance Class to BP_GameInstance .
BP_Slime (parent: Character)
| Element | Setting |
|---|---|
Variable EnemyID (Name) | Default Slime |
| How it dies | For testing, a key press that Destroy s one on the spot is enough. Fire OnEnemyKilled right before it dies |
Wiring accept, kill, and report
To keep verification easy, we'll accept, kill, and report with key presses. In a real game, accepting and reporting would be called from dialogue choices in the dialogue article.

BP_ThirdPersonCharacter (event graph)
Keyboard Event: J (Pressed) <- accept
→ Get Game Instance → Cast To BP_GameInstance
→ AcceptQuest (Definition: DA_Quest_SlimeHunt)
→ Print String ("Quest accepted")
Keyboard Event: K (Pressed) <- stands in for killing one slime
→ Get Game Instance → Cast To BP_GameInstance
→ Call OnEnemyKilled (EnemyID: "Slime")
Keyboard Event: L (Pressed) <- report
→ Get Game Instance → Cast To BP_GameInstance
→ CompleteQuest (QuestID: "SlimeHunt")
Success → Branch
True → Print String ("Reported! Gold: " + TotalGold)
False → Print String ("You can't report yet")
To see the progress, drop a Print String ("Progress: " + CurrentCount + " / " + RequiredCount) right before the Call OnQuestUpdated in HandleEnemyKilled so you can follow the count (→ the Print String article).
Verifying it
Play and press the keys in this order.
| Action | Expected output |
|---|---|
L (report before accepting) | You can't report yet . Not accepted yet |
J | Quest accepted |
K once | Progress: 1 / 3 |
L (report before the goal) | You can't report yet . The state is in progress |
K twice more | Progress: 2 / 3 → Progress: 3 / 3 . On the third the state becomes reached goal |
L (report) | Reported! Gold: 100 |
L (report again) | You can't report yet . Gold stays at 100 (never 200) |
K (kill after completing) | Nothing advances. The state isn't in progress, so it isn't counted |
The last three rows are what this hands-on is really about. Reporting too early, reporting twice, and counting after completion are all stopped by the same single "look at the state" check. They aren't blocked by separate flags.
Troubleshooting if it doesn't work.
- Pressing
Kdoesn't printProgress: 1 / 3→ Game Instance Class isn't set and the Cast is failing, or you didn't BindOnEnemyKilledinEvent Init - The count never leaves
0 / 3→ theAddwrite-back at the end ofHandleEnemyKilledis missing.Set Membersonly edited a copy - It says
You can't report yeteven after reaching the goal →RequiredCountis blank (still 0), soCurrentCount >= 0was satisfied from the start and the check is off. Or theSet MembersState update isn't connected - Reporting gives you 200 gold → the
State == ReachedGoalbranch at the top ofCompleteQuestis missing, so completed quests still pay out Kstill counts after completing →State == InProgressis missing from the check inHandleEnemyKilled
Now try changing RequiredCount on DA_Quest_SlimeHunt to 5 and playing again. Without opening a single Blueprint, only the required kill count becomes 5. That's the payoff for separating the definition from the progress state.
Two things to take away.
- Progress is driven by an event you listen for, not one you go fetch: the enemy just broadcasts that it died and knows nothing about quests. Goblin hunts and boss kills are handled by adding one more quest asset with a different
TargetEnemyID. The enemy side doesn't change by a single character - Judge "is it over?" from the state alone: separating reached-goal from completed means both premature and double completion are stopped by the one gate
State == ReachedGoal. No separate flag remembering whether the reward was paid
To keep this progress for the next launch, continue to the save/load article; to accept and report through dialogue choices, continue to the dialogue system article; to build out the tracker's visuals, continue to the UMG article.
Bonus: Good to Know for Later
- Other objective types are just an extension of the
TargetEnemyIDidea: "go to X" and "talk to X" work the same way, with an area trigger or dialogue event broadcastingOnReachedAreaorOnTalkedToand the quest side looking at the matching definition. Add one Enum for "objective type (kill / reach / talk / collect)" to the definition and a single quest system handles multiple objective types - Collection quests pair well with the inventory: "gather 3 herbs" is built almost identically to a kill quest by subscribing to the inventory's OnInventoryChanged and reading the count. All that changes is what you're counting: enemies killed becomes items held
- For quests with several objectives, make the objectives an array: a compound quest like "kill 3 slimes and visit the shrine" holds an array of objectives inside
S_QuestProgressand moves toReachedGoalonce all of them are met. The mechanism stays the same while the objective count grows - A quest log is just
ActiveQuestslaid out: an accepted-quest list screen comes from looping theKeysofActiveQuestsand displaying each quest'sTitleand progress. Maps don't guarantee order, so if you want a display order, give the definition aSortOrderand sort by it - To stop repeats of finished quests, keep a separate completed list: if you design things so completed quests are removed from
ActiveQuests, keeping "IDs of finished quests" in a separate array (CompletedQuestIDs) lets you reject re-accepting the same quest. Keep that array in the Game Instance too and include it in your saves
Summary
- Split a quest into three parts: definition (Data Asset), progress state (a Game Instance variable), notification (Dispatcher) . What doesn't change and what does go in different places
- Use an Enum with four states (not started / in progress / reached goal / completed). Only one holds at a time
- Keep progress in a
Map<QuestID, S_QuestProgress>on the Game Instance so it survives level changes - Progress is driven by the enemy-death event . The enemy just broadcasts; the quest listens and counts. The struct you pulled with
Findmust be written back - Separating reached-goal from completed stops both premature and double completion with the single check
State == ReachedGoal
In the game you're building now, what's the first objective you give the player? Start that one as a single DA_Quest_X Data Asset.