[UE5] Building a Quest and Objective System: Making "Kill 3 Slimes" Work

Created: 2026-07-20

Build the objective "kill 3 slimes" out of three parts: the definition (Data Asset), the progress state (GameInstance), and notification (Dispatcher). Narrow states to four with an Enum, advance the count from an enemy-death event, and separate reaching the goal from completing it to block double completion, with a hands-on quest that counts up as you kill.

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

A quest tracker reading "Slime 0/3" alongside a soft blue figure looking up at the objective

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

Sponsored

A Quest Is Made of Three Parts

Before building anything, split a quest into three parts . Blend them together and it becomes unmanageable later.

Diagram of the three quest parts: the definition (Data Asset) is the unchanging design, the progress state (a Struct in the GameInstance) changes during play, and notification (Dispatcher) announces the changes, each with its own role
PartWhat it holdsWhen it changesWhere it lives
DefinitionThe objective's content (what, how many, what reward)Only during development tuningData Asset
Progress stateHow many so far, which state it's inConstantly during playA variable on the Game Instance
NotificationAnnounces that something changedEvery time it advancesEvent 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 nameTypeExample
QuestIDNameSlimeHunt
TitleTextSlime Extermination
DescriptionTextKill 3 slimes
TargetEnemyIDNameSlime
RequiredCountInteger3
RewardGoldInteger100

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 nameTypeContents
DefinitionBPDA_QuestDefinition (object reference)Which quest this is
StateE_QuestState (created in the next section)Which stage it's at
CurrentCountIntegerHow 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.

Sponsored

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.

Quest state transition diagram moving one way from Not Started to In Progress to Reached Goal to Completed, with the transition to Completed allowed only from Reached Goal
StateMeaningCondition to move on
NotStartedNot accepted yetAccept it
InProgressAccepted and working toward the goalThe count reaches the target
ReachedGoalThe goal is met but not reported yetReport it
CompletedReported 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.

A cross-level diagram where accepting in Level1, killing three in Level2, and returning to Level1 to report all sit above a single Game Instance band that holds the progress state throughout

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

KindNameContents
VariableActiveQuestsMap with QuestID (Name) as key and S_QuestProgress as value
VariableTotalGoldInteger. Where rewards land. Default 0
Event DispatcherOnQuestUpdatedHas one Name output. Fires when a quest moves
Event DispatcherOnEnemyKilledHas 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: Definition is 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 become None in 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.

Sponsored

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.

Flow from enemy death to the count advancing: BP_Slime dies and fires OnEnemyKilled, the GameInstance hears it and HandleEnemyKilled advances the count 0/3 to 1/3 to 2/3 to 3/3

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.

Node graph of HandleEnemyKilled: loop over ActiveQuests Keys with ForEach, pull progress with Find, increment CurrentCount when in progress and the enemy matches, set ReachedGoal when the target is hit, write it back, and fire OnQuestUpdated
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.

Node graph of CompleteQuest: Find the progress, and only when the state is ReachedGoal set Completed, add the reward, and return Success. Anything else does nothing and returns false
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 through False and does nothing
  • Double completion (reported again after completing): the state is Completed , so it also exits through False

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

Sponsored

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.

Hands-on flow: accept at 0/3, kill three slimes for 1/3 to 2/3 to 3/3, reach the goal and see "go report it", report for +100 gold and completion, with a second report doing 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

VariableValue
QuestIDSlimeHunt
TargetEnemyIDSlime
RequiredCount3
RewardGold100

BP_GameInstance (parent: GameInstance)

KindNameType / default
VariableActiveQuestsMap (Name → S_QuestProgress)
VariableTotalGoldInteger / 0
DispatcherOnQuestUpdatedName output
DispatcherOnEnemyKilledName 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)

ElementSetting
Variable EnemyID (Name)Default Slime
How it diesFor 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.

Hands-on node graph: a key press accepts the quest, the slime fires OnEnemyKilled, another key reports by calling CompleteQuest, and Print String shows the result
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.

ActionExpected output
L (report before accepting)You can't report yet . Not accepted yet
JQuest accepted
K onceProgress: 1 / 3
L (report before the goal)You can't report yet . The state is in progress
K twice moreProgress: 2 / 3Progress: 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 K doesn't print Progress: 1 / 3 → Game Instance Class isn't set and the Cast is failing, or you didn't Bind OnEnemyKilled in Event Init
  • The count never leaves 0 / 3 → the Add write-back at the end of HandleEnemyKilled is missing. Set Members only edited a copy
  • It says You can't report yet even after reaching the goalRequiredCount is blank (still 0), so CurrentCount >= 0 was satisfied from the start and the check is off. Or the Set Members State update isn't connected
  • Reporting gives you 200 gold → the State == ReachedGoal branch at the top of CompleteQuest is missing, so completed quests still pay out
  • K still counts after completingState == InProgress is missing from the check in HandleEnemyKilled

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.

Sponsored

Bonus: Good to Know for Later

  • Other objective types are just an extension of the TargetEnemyID idea: "go to X" and "talk to X" work the same way, with an area trigger or dialogue event broadcasting OnReachedArea or OnTalkedTo and 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_QuestProgress and moves to ReachedGoal once all of them are met. The mechanism stays the same while the objective count grows
  • A quest log is just ActiveQuests laid out: an accepted-quest list screen comes from looping the Keys of ActiveQuests and displaying each quest's Title and progress. Maps don't guarantee order, so if you want a display order, give the definition a SortOrder and 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 Find must 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.