A quest is an objective that can be completed and grants rewards upon completion. Progression tracks a quest through its lifecycle. From the moment players join, their ability to complete a quest is tracked through objectives. When an objective is complete, the quest is complete and a reward is granted to members of the quest.
Progression APIs create a questing framework that provides a way to script a complete questing system without the use of Fortnite devices. You can use this framework to build quests of varying sizes and depths. For more information on the questing framework, see the Questing Systems Overview.
In this tutorial you’ll learn:
How the progression classes work together to build the quest.
How players join, abandon, and complete a quest.
How rewards are handled.
How to create and modify the progression model and instancing of the quest.
Before You Start
To best understand the content in this tutorial:
Review the Progression API references for Verse, UnrealEngine, and Fortnite.
Have a fundamental understanding of how to use Verse. To learn more, see the Verse Programming Onboarding Guide.
This example uses a Verse device. To create one, see Create Your Own Device Using Verse.
Read the Questing System Overview for indepth information about the Progression APIs and the different parts that make up a quest.
You need these devices in your level:
One Button device that players use to abandon the quest.
One or more Collectible Object devices to use as coins.
One Score Manager device to grant the reward.
One Item Granter device to grant a keycard.
The quest uses two Verse files. Create both in the same Verse folder in your project, so that the device file can use the HUD class:
coin_quest_device.verse— The quest, the objective, and the device.coin_quest_ui.verse— The coin counter that appears in each player's HUD.
This tutorial uses two terms:
Agent — A player in the running game session.
Participant — An agent that the quest tracks as a member.
The System’s Core Quest Mechanics
In the Coin Quest game, players have a goal to collect silver and gold coins. The quest tracks the player’s progression throughout the quest by counting how many gold and silver coins are collected. When the coin tally reaches 10,000, each player receives 10,000 points.
Players join the quest automatically at the start of the game and coins collected by players are added to the group total. Each player has visibility into the coin contributions of quest members in the HUD where Silver and Gold coins are tallied as they are collected.
Players can abandon the quest at any time. They are removed from the quest and the quest rewards when they abandon and leave the quest before the quest is completed. Rewards are only granted on quest completion.
Questing Classes
The device builds the quest from the following progression classes:
coin_quest_device— Creates the quest, joins and tracks players, counts coins, completes the quest, awards score, and updates HUDs.coin_quest—The quest itself. Adds a typedCoinObjectivefield so the device can callUpdateCoinProgress; the inheritedObjectivefield is typed as the base objective class.coin_objective— Converts coin counts into progress: (gold ×GoldCoinValue) + (silver ×SilverCoinValue), passed toSetProgress.keycard_quest_reward— Holds the reward's icon, name, and descriptions, plus the Item Granter that hands out the keycard.OnGrantis empty on purpose (see Complete the Quest and Grant Rewards).coin_collection_listener— One per coin. Remembers whether the coin is gold, blocks double pickups, hides the coin, and forwards the pickup to the device.coin_quest_hud— One per player. Draws and refreshes the gold and silver counter text.
The coin_quest_device script runs everything. In OnBegin, it builds the objective, reward, and quest, then joins players, subscribes to coin pickups and the abandon button, and pushes updated counts to each player's HUD as coins are collected.
How the Classes Work Together
The classes form the following layers:
The device layer connects the quest to your island. The
coin_quest_deviceclass listens for players entering and leaving, button presses, coin pickups, and reward events, and passes them to the other layers.The quest layer manages people. The
coin_questclass decides who is a member. When the quest is done, it rewards its members.The objective layer manages progress. The
coin_objectivekeeps the coin totals and decides whose coins count.The UI layer shows information to players. Each
coin_quest_huddisplays one player's coin counts.The
coin_collection_listenerhandles each coin’s collection event. It remembers whether that coin was already collected, hides it, and tells the device whether it was gold or silver.
The Coin Quest Inheritance System
When the device creates the quest, it assigns the same coin_objective instance to both CoinObjective and the base quest Objective field. That way, custom code can update coin-specific progress while the base quest framework can treat it as a regular objective. Rewards are stored as []quest_reward, so the collection can hold the custom reward through its parent type.
coin_questextendsbasic_quest. It gets the standard quest behavior and adds a strongly typedCoinObjectivefield so the device can update coin progress.coin_objectiveextendsprogress_objective. It calculates total coin value and passes it to the inheritedSetProgressmethod.keycard_quest_rewardextendsquest_reward. It supplies the reward icon and text by overriding inherited fields, and overridesOnGrant. The actual item-grant handling is connected to its grant event.
Modules and Libraries
The following modules are added to the top of the script. This provides a way for Verse to find the types the device uses:
/Fortnite.com/Devices— The Button, Collectible Object, and Score Manager devices./Fortnite.com/Playspaces— The playspace, which tells the device when players enter and leave the game./Verse.org/Simulation— Core types, including agent and player./Verse.org/Progression— Imports the core quest and progression types this device uses; it doesn’t run the coin logic itself./UnrealEngine.com/Progression— Imports Unreal’s quest-framework classes into the file.
On Quest Start
OnBegin builds the quest, then joins every player already in the game and every player who arrives later.
Build the objective. A
coin_objectiveis created withRequiredProgress := CoinTarget * 1.0.Build the reward. If both
KeycardRewardandKeycardRewardIconare set, akeycard_quest_rewardis created, itsGrantEventis subscribed toHandleKeycardRewardGranted, and the Item Granter is enabled.Build the collection. A
quest_collectionis created and itsJoinEventis subscribed toHandleQuestMemberJoined.Build and activate the quest. A
coin_questis created with the same objective assigned to bothCoinObjectiveandObjective, its rewards are set withSetRewards, andActivate()starts it.Join players. The device subscribes to
PlayerAddedEventbefore looping overGetPlayers(), so no player is missed. Both paths callJoinPlayer.
OnBegin<override>()<suspends>:void =
if (CoinTarget > 0, GoldCoinValue > 0, SilverCoinValue > 0):
NewObjective := coin_objective:
RequiredProgress := CoinTarget * 1.0
if (KeycardGrant := KeycardReward?, RewardIcon := KeycardRewardIcon?):
RewardData := keycard_quest_reward:
Join Players to the Quest
The JoinPlayer function skips anyone who already has a membership. For a new player, it wraps the agent in an agent_quest_participant, creates a quest_participant_info with contribute, observe, and receive all set to true, and calls Quest.Join.
The membership is stored by the collection's JoinEvent, not by JoinPlayer. HandleQuestMemberJoined adds it to QuestMemberships and calls EnsurePlayerHUD to give the player a coin counter.
| Participant Flag | Action |
|---|---|
Contribute | The player’s actions advance the quest. |
Observe | The player can see the quest. |
Receive | The player is eligible for quest rewards. |
Quest Progress
Each coin pickup flows from the coin's listener to the device, which, in turn, updates the team total and the objective.
Listener. In
OnBegin, the device creates onecoin_collection_listenerper coin and subscribes it to that coin'sCollectedEvent. On pickup,HandleCollectedignores repeat pickups (WasCollected), hides the coin, and callsOwner.HandleCoinCollected(Agent, IsGold).Membership check.
HandleCoinCollectedreturns early if the quest is already complete. Otherwise, it checks whether the agent has a membership inQuestMemberships.Count the coin. For members only, the device increments the shared team total (
CollectedGoldCoinsorCollectedSilverCoins) and the player's own count (GoldCountsByPlayerorSilverCountsByPlayer).Update the HUD and objective. The device calls
UpdatePlayerHUD(Agent), thenUpdateObjective().UpdateObjectivepasses the shared team totals toCoinObjective.UpdateCoinProgress, which callsSetProgresswith the total coin value.
A non-member who picks up a coin still hides it for everyone, but the coin adds nothing to the quest.
Abandon the Quest
A player abandons the quest by interacting with the AbandonButton button device, which calls HandleAbandon(Agent).
The device finds the player's membership and calls
Quest.Abandon(Membership.Participant).The device rebuilds
QuestMembershipswithout that player.
After abandoning:
Coins the player picks up no longer count, because the membership check fails.
The player gets no score award or keycard when the team completes the quest.
Coins the player collected before abandoning stay in the team total.
The player's HUD stays on screen, and its counts stop changing.
The player can't rejoin.
JoinPlayerruns only at game start or when a new player joins the playspace.
Complete the Quest and Grant Rewards
The quest completes inside UpdateObjective the first time the objective's State reads objective_state.Complete after a pickup.
Completion
CompletionHandledis set to true, so completion runs once and later pickups are ignored.Quest.Complete(succeed_objective_reason{})completes the quest.ScoreManager.SetScoreAward(ScoreAward)sets the score, andScoreManager.Activateruns once for each current member.
The score award is handled by the device directly, not by the quest reward system.
Keycard Reward
The keycard uses the quest reward system, split across two steps:
Grant. Completing the quest grants the rewards set with
SetRewardsto eligible members. The reward'sOnGrantis empty, becauseOnGrantis<transacts>anditem_granter_device.GrantItemcan't be rolled back, so it can't be called there.Deliver. The reward's
GrantEventfires with the recipients.HandleKeycardRewardGrantedcasts the reward tokeycard_quest_rewardand callsItemGranter.GrantItemfor each recipient's agent.
The pattern of empty OnGrant works in a GrantEvent handler and applies OnGrant to any reward that calls a device function marked no-rollback. This stops the quest logic from granting rewards twice to players who have already completed the quest.
Show Quest Info in the HUD
Each member gets one coin_quest_hud, stored in the device PlayerHUDs map. Every player’s HUD shows that player's own gold and silver counts, not the team total.
Create the HUD
HandleQuestMemberJoinedcallsEnsurePlayerHUD(Agent).If the player has no HUD yet, the device creates one, seeds it with
SetCounts, and stores it in PlayerHUDs.The device spawns
InitializePlayerHUD, which callsHUD.Initialize(Player)and, on success,UpdateText().
Draw the HUD
The Initialize function gets the player's UI with GetPlayerUI[Player], sets the text_block from the CoinCounterMessage localized message, places it on a canvas at (40, 80), and adds the canvas to the player's UI. It returns false if the player has no UI.
Update the HUD
After each pickup, UpdatePlayerHUD reads the player's counts from GoldCountsByPlayer and SilverCountsByPlayer, then calls SetCounts and UpdateText on that player's HUD.
| HUD Function | What it Does |
|---|---|
| Adds the counter to the player's screen. |
| Stores new counts. Doesn't redraw. |
| Redraws the text from the stored counts. |
To change the counter's wording, edit CoinCounterMessage. To move it, change the vector2 in Initialize.
Change to Individual Quests
The Progression API digest has no instance-type setting, so individual mode is built by giving each player their own coin_quest. Each player then collects every coin once toward their own CoinTarget, completes on their own, and gets their own score award and keycard.
Step 1: Replace Shared State with Per-Player State
In coin_quest_device, delete MaybeQuest, CompletionHandled, CollectedGoldCoins, and CollectedSilverCoins. Add these fields:
var QuestsByPlayer:[agent]coin_quest = map{}
var CompletedPlayers:[agent]logic = map{}
var MaybeCollection:?quest_collection = false
var QuestRewards:[]quest_reward = array{}
Keep GoldCountsByPlayer and SilverCountsByPlayer. They now drive both the HUD and each player's progress.
Step 2: Track Pickups per Player
Replace coin_collection_listener so each coin tracks who collected it and stays in the world for other players:
coin_collection_listener := class:
Coin:collectible_object_device
Owner:coin_quest_device
IsGold:logic
var CollectedBy:[agent]logic = map{}
HandleCollected(Agent:agent):void =
if (not CollectedBy[Agent]):
if (set CollectedBy[Agent] = true):
Owner.HandleCoinCollected(Agent, IsGold)
The Coin.Hide() call is removed.
Step 3: Create One Quest Per Player
Add a function that builds and activates a quest for one player:
CreateQuestFor(Agent:agent):?coin_quest =
if (Collection := MaybeCollection?):
NewObjective := coin_objective:
RequiredProgress := CoinTarget * 1.0
NewQuest := coin_quest:
CoinObjective := NewObjective
Objective := NewObjective
Collection := Collection
NewQuest.SetRewards(QuestRewards)
ActivationResult := NewQuest.Activate()
Step 4: Update OnBegin
In OnBegin, keep the validation, reward setup, coin listener setup, and abandon button subscription. Replace the objective, collection, and quest block with:
if (RewardData := MaybeKeycardQuestReward?):
set QuestRewards += array{RewardData}
NewCollection := quest_collection{}
NewCollection.JoinEvent.Subscribe(HandleQuestMemberJoined)
set MaybeCollection = option{NewCollection}
GetPlayspace().PlayerAddedEvent().Subscribe(HandlePlayerAdded)
for (Player : GetPlayspace().GetPlayers()):
JoinPlayer(Player)
Remove the var QuestRewards local because it's now a field.
Step 5: Join Each Player to Their Own Quest
JoinPlayer(Agent:agent):void =
if (not QuestsByPlayer[Agent]):
MaybeNewQuest := CreateQuestFor(Agent)
if (Quest := MaybeNewQuest?):
Participant := agent_quest_participant:
Agent := Agent
Info := quest_participant_info{}
Info.SetContribute(true)
Info.SetObserve(true)
Info.SetReceive(true)
HandleQuestMemberJoined and EnsurePlayerHUD don't change.
Step 6: Count Coins and Complete per Player
Replace HandleCoinCollected and UpdateObjective:
HandleCoinCollected<public>(Agent:agent, IsGold:logic):void =
if (not CompletedPlayers[Agent]):
var IsMember:logic = false
for (Membership : QuestMemberships):
if (
Participant := agent_quest_participant[Membership.Participant],
Participant.Agent = Agent
):
set IsMember = true
Only the completing player gets the score award and keycard.
Step 7: Abandon the Player's Own Quest
In HandleAbandon, replace if (Quest := MaybeQuest?): with if (Quest := QuestsByPlayer[Agent]):. The rest of the function doesn't change.
Step 8: Update the HUD Comments
The HUD already shows per-player counts, so coin_quest_ui.verse needs no code changes. Update its comments that say "shared coin totals" to say "this player's coin counts."
Add Persistence to a Quest
The quest system does not save quests for you. When a player leaves an island their quest, objective, membership, and coin counts are gone. By adding persistence, you can save an individual player’s version of the coin quest. The following steps work with the Change to Individual Quests section.
If you use the persistence information in this section, a returning player can pick up where they left off. The save finds each quest by an ID you choose, so the same record can hold more quests later.
Persistence uses the individual version, not the shared one. A shared quest keeps its progress on one quest instance for the whole session. Verse has no persistent key for a session, which means there is nowhere to save the persistence information to. Each player could only save a copy of the team total, and those copies would disagree. In the individual version, each player owns their own coin_quest, so each player's progress fits in one record keyed to that player.
What to Save
Save one record per player. It holds an entry for each quest, found by the quest's ID, and the player's two coin counts.
Quest ID — The framework gives a quest no ID of its own. Choose one, for example the string constant
CoinQuestId. It is the key the quest's entry is found by when the player rejoins. Never change it after you publish. A changed ID finds no entry, and every returning player starts the quest over.Status — The
objective_stateis not a persistable enum, and once a quest completes its State only readsComplete. Declare your ownenum<persistable>with NotStarted,InProgress,Succeeded, andAbandoned.Progress — Save the objective's Progress. Do not save
RequiredProgress. The target belongs to the device, so a balance change applies to returning players too. If you lowerCoinTargetbelow a returning player's saved progress, their quest completes and pays when they rejoin.Coin counts — Save the ints from
GoldCountsByPlayerand SilverCountsByPlayer on the record, next to the quests. The counts are player data, not quest data. The HUD shows them, and the coin quest computes its progress from them. When a player rejoins the quest, the device restores the saved progress, and the next pickup recomputes it from the counts. A change toGoldCoinValueorSilverCoinValuetherefore reaches a returning player at their next pickup.
Every quest entry has the same shape, status and progress value, so the same type works for any quest on the island. A saved record cannot be changed in place, because a persistable class cannot have var fields. To update one quest, build a new record from the updated map of entries and the current counts, and write it back.
You cannot save the quest, objective, membership, or agent. None of them are persistable. The device rebuilds them from its own settings when the player joins.
When to Save
In this sample, the device saves at the end of a new function with CheckCompletionFor. Every counted pickup and every join that rebuilds the quest ends there. That is the point where the counts, HUD, objective, and completion check already agree.
The save needs no extra listener per player. CheckCompletionFor uses the default function effects, Verse does not let you call it inside a failure context, and nothing can undo the change after it has been saved.
Using a player who has left as a key in a persistent weak_map is a runtime error, for reads and writes alike. Guard every access with Player.IsActive[], and save as coins are collected. Do not wait for the player to leave.
Restoring OnJoin
When a player joins, read their record, then look up the coin quest's entry by its ID. A missing entry reads as NotStarted. Act on its status:
NotStartedorInProgress— Put the saved counts back intoGoldCountsByPlayerand SilverCountsByPlayer first. The membership'sJoinEventcreates the HUD, andEnsurePlayerHUDreads those maps, so the HUD shows the restored counts. Then build the quest, join the player, callSetProgresswith the saved progress, and callCheckCompletionFor. That is the same completion check a pickup runs. Saved progress at or over the target completes the quest, pays the score award, keycard, and recordsSucceeded.
Succeeded— Do not rebuild the quest. A rebuilt quest that completes again pays its rewards again — the keycard through the reward'sGrantEventand the score award from the device. Show the saved counts on the HUD instead.
Abandoned— Abandoning does not complete or fail a quest.Abandonremoves the membership, noCompleteEventfires, and the quest stays active with no members. The device recordsAbandoneditself, and when a player rejoins the quest, treats it like a finished quest. This carries the rule from Abandon the Quest, where a player who abandons cannot rejoin, into later sessions. If you want a player who abandoned it to start over in their next session, saveNotStartedwith zero progress instead.
Join before you call SetProgress. The keycard pays only to members whose Receive is true, and a completed quest refuses new members.
Add More Quests
The sample has one quest, but the save is built for more. A second quest needs its own ID constant, and its code calls LoadQuest and SaveQuest with that ID. Its entry goes into the same Quests map, in the same record and the same persistent weak_map. The saved types and the load and save functions do not change. SaveQuest replaces only the entry for its own ID, so saving one quest keeps the others. In JoinPlayer, restore the second quest before the coin quest's early return, and restore the counts before any quest saves, because every save writes the counts.
Changes to the Individual Coin Quest
Make these changes in coin_quest_device.verse, the coin_quest_ui.verse file does not change.
Step 1: Add the Quest ID and Saved Types
Add these at the top level of the file, outside of any class. For example, after coin_quest.
# The key to this quest's entry is saved under. Never change it after you publish.
CoinQuestId:string = "coin_quest"
# objective_state is not persistable, so the save uses its own status.
# A closed enum cannot gain values after you publish; declare it
# enum<open><persistable> if you might add statuses later.
saved_quest_status := enum<persistable>:
NotStarted
InProgress
Step 2: Add Load and Save Functions
Add these to coin_quest_device. LoadQuest and SaveQuest take a quest ID, so any quest on the island can use them.
# Using a player who has left as a key is a runtime error, so check IsActive first.
LoadPlayerData(Agent:agent):saved_player_data =
if:
Player := player[Agent]
Player.IsActive[]
Record := PlayerSaves[Player]
then:
Record
else:
saved_player_data{}
Step 3: Split UpdateObjectiveFor
Replace UpdateObjectiveFor with these two functions. The completion check moves into CheckCompletionFor with the same logic, and the save is added at its end.
UpdateObjectiveFor(Agent:agent):void =
if (Quest := QuestsByPlayer[Agent]):
GoldCount := GoldCountsByPlayer[Agent] or 0
SilverCount := SilverCountsByPlayer[Agent] or 0
Quest.CoinObjective.UpdateCoinProgress(GoldCount, SilverCount, GoldCoinValue, SilverCoinValue)
CheckCompletionFor(Agent)
# A counted pickup and a restore on join both end here, so this is the one
# place that completes the quest and the one place that saves it.
Step 4: Restore in JoinPlayer
Replace JoinPlayer with the script below.
JoinPlayer(Agent:agent):void =
if (QuestsByPlayer[Agent]):
return
Record := LoadPlayerData(Agent)
# Restore the counts before Join, so the HUD that JoinEvent creates shows them.
if (set GoldCountsByPlayer[Agent] = Record.GoldCount) {}
if (set SilverCountsByPlayer[Agent] = Record.SilverCount) {}
Entry := LoadQuest(Agent, CoinQuestId)
# Finished in an earlier session: do not rebuild the quest, so nothing pays again.
if:
Step 5: Record an Abandon
At the end of HandleAbandon, inside the if (Quest := QuestsByPlayer[Agent]): block, add the lines after set QuestMemberships = RemainingMemberships.
set QuestMemberships = RemainingMemberships
# Abandoning does not complete the quest, so record it here.
# A player who already finished keeps Succeeded.
if (not CompletedPlayers[Agent]):
SaveQuest(Agent, CoinQuestId, saved_quest_status.Abandoned, Quest.CoinObjective.Progress)
What the example does:
The saved_player_data class is the whole save: a map of quest entries keyed by quest ID and two ints. Each
saved_questentry is a status and a float.
The
CheckCompletionForfunction writes the coin quest's entry after every counted coin, and once more when a player joins and their quest is rebuilt. The write on the completing pickup recordsSucceeded. After that,HandleCoinCollectedignores the player becauseCompletedPlayersis set, so theSucceededentry is final.
The
SaveQuestclass reads the player's current record, replaces the entry for one ID, and writes a new record with the current counts. Entries for other IDs are kept.
The
HandleAbandonfunction writes Abandoned unless the player has already finished. After an abandon, the player is no longer a member, this means the player's later pickups do not count and do not overwrite the entry.
The
JoinPlayerfunction restores the counts before the join, and the HUD shows them. A player who finished or abandoned gets their HUD and nothing else. Everyone else gets a fresh quest, joins it, has their saved progress put back with SetProgress, and goes throughCheckCompletionFor.
Limits of this Example
Collected coins are not saved. Each coin's
CollectedBymap lives only for the session. A returning player can collect the same coins again, and those count toward their total. To save which coins a player collected, give each coin a stable ID — for example, its index inGoldCoinsorSilverCoinsif you never reorder those arrays. Add a field such asCollectedGold:[]int = array{}tosaved_player_data, and refill each listener'sCollectedBywhen the player joins.
Do not overwrite a finished entry. A player who finished can still press the abandon button, and Abandon works on a completed quest. The
CompletedPlayerscheck in Step 5 keepsSucceededfrom turning intoAbandoned.
The player must be active when they join.
LoadPlayerDatatreats a player who is not yet active the same as a player with no record. The device then restores zero counts, and a later save can write them over the real record. This sample assumes a player is active by the timePlayerAddedEventfires, which is the same assumption as the persistable data examples.The worst case is a player who has already finished or abandoned: they are rebuilt as
NotStarted, can complete the quest and be paid again, and their entry is overwritten. If you see returning players starting from zero, logPlayer.IsActive[]inHandlePlayerAdded. If it fails, return fromJoinPlayerand join the player later instead.
Plan for change. Fields added to a published persistable class must have defaults. A closed persistable enum cannot gain values once published, decide on your statuses before you publish, or declare the enum
enum<open><persistable>. Never rename or reuse a quest ID after publishing.
Use one persistent map for the island. The number of persistent weak_maps an island can have is limited. Keep every quest in the one record per player, as this sample does, rather than adding a
weak_mapper quest.
Coin Quest Complete Code
coin_quest_device
using { /Fortnite.com/Devices }
using { /Fortnite.com/Playspaces }
using { /UnrealEngine.com/Progression }
using { /Verse.org/Assets }
using { /Verse.org/Simulation }
using { /Verse.org/Progression }
#The following messages are used for the keycard reward.
KeycardRewardDescription<localizes>:message =
"A keycard reward for completing the coin quest."
coin_quest_ui
using { /Fortnite.com/UI }
using { /UnrealEngine.com/Temporary/SpatialMath }
using { /UnrealEngine.com/Temporary/UI }
using { /Verse.org/Simulation }
CoinCounterMessage<localizes>(GoldCoinCount:int, SilverCoinCount:int):message =
"Gold Coins: {GoldCoinCount} Silver Coins: {SilverCoinCount}"