Quest System¶
Summary: Tag-graph quest system — the player's quest state IS a replicated
FGameplayTagContainer, and all narrative progression is expressed as tag grants. There is deliberately no objective-checklist engine and no quest log: the Compendium (journal of discovered lore entries) is the player-facing record.UQuestManagerComponenton GameState holds the global registry;UPlayerQuestComponenton PlayerState tracks per-player tags. Dialogue (DlgSystem), item pickups, lore actors, and world triggers all funnel into the same server-authoritativeAddQuestTagpath.
Table of Contents¶
- Design Philosophy
- Architecture Overview
- Tag Conventions
- Quest Flow
- Component Responsibilities
- Quest Data Model
- Quest Rewards
- Compendium System
- Dialogue Integration
- Multiplayer
- Public Contracts
- Gotchas
- Source References
- Related Systems
- Recent Changes
Design Philosophy¶
The tag-graph model is final (WorldSystemsScalingReview RULING-8, Oliwer 2026-07-07):
- Tags are the quest state. A quest's progression is the set of
Quest.*tags a player has. There are no per-objective booleans, no counters, no stage machine — a "stage" is just a tag someone granted. - No quest log. The GDD's narrative stance (story through environments, items, echoes) means the player-facing
surface is the Compendium: lore entries unlock when their
RequiredTagis granted, and the journal doubles as the quest record. - Everything funnels through
AddQuestTag. Dialogue events, item pickups (FQuestFragment), lore actors (ALoreFragmentActor), world-hint triggers, and quest triggers all end up in the same server-authoritative grant path, which also resolves compendium unlocks. - NPCs branch on tags. Dialogue graphs check tags via
UQuestDialogueConditionrather than querying quest objects. FromSoft-style: the world reacts to what you've done, it doesn't enumerate what's left to do.
Do NOT add checklist-engine features (visibility conditions, per-objective completion flags, prerequisite chains) — they were built speculatively once, never read by anything, and deleted in the 2026-07 Phase 1 cleanup.
Architecture Overview¶
┌─────────────────────────────────────────────────────────────┐
│ AEternalGameState │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ UQuestManagerComponent (Global) │ │
│ │ • Quest Data Asset Registry (asset-registry scan) │ │
│ │ • Compendium Entry Registry (RequiredTag → entry) │ │
│ │ • Global Events (FGameplayTagContainer, replicated) │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ AEternalPlayerState │
│ ┌─────────────────────────────────────────────────────── ┐ │
│ │ UPlayerQuestComponent │ │
│ │ • QuestTags / ActiveQuests / CompletedQuests │ │
│ │ • all replicated COND_OwnerOnly │ │
│ └─────────────────────────────────────────────────────── ┘ │
│ (GetCompendiumSystem() accessor only — see below) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ AEternalPlayer (PlayerController) │
│ ┌─────────────────────────────────────────────────────── ┐ │
│ │ UCompendiumSystem │ │
│ │ • Discovered entries (TMap, NOT replicated) │ │
│ │ • Read/unread state, persistence save/load │ │
│ └─────────────────────────────────────────────────────── ┘ │
└─────────────────────────────────────────────────────────────┘
Note the compendium's home: UCompendiumSystem is created on the PlayerController
(EternalPlayer.cpp constructor). AEternalPlayerState::GetCompendiumSystem() is just an accessor. Older docs
claimed it lived on PlayerState — wrong.
Key Design Principles¶
| Principle | Implementation |
|---|---|
| Tag-Based | All quest state tracked via FGameplayTag |
| Server-Authoritative | All mutations authority-gated; clients go through Server RPCs |
| Dual Components | Global registry + per-player tracking |
| Data-Driven | Quests defined entirely through data assets |
| Single Grant Funnel | Every grant path converges on AddQuestTag → compendium unlock → client toast |
Tag Conventions¶
Authored tags follow this shape (see Config/Tags/GameplayTags.ini, Quest.* block):
Quest.[Name] root quest tag (registered to a UQuestDataAsset)
Quest.[Name].Discovery.[How] discovery grants (e.g. Quest.BeautifulFountain.Discovery.NPCMentioned)
Quest.[Name].Objective.[What] objective grants (e.g. Quest.BeautifulFountain.Objective.PickedUpHead)
Quest.[Name].Completed granted automatically by CompleteQuest
CompleteQuestsynthesizes the completion tag by string append:QuestTag + ".Completed"(PlayerQuestComponent.cpp). The tag must exist in the ini for the grant to resolve.- There is no
.Stage.segment. GenericQuest.Stage.*/Quest.SubEvent.*scaffolding tags and theGetQuestDiscoveryTag/GetQuestCompletionTaghelpers that derived them were dead code from an idealized early model and were deleted (2026-07 Phase 1). Shipped content never used them. - Tags are hand-authored in the ini today; the NarrativeAuthoringWorkflow manifest→stamp pipeline will generate them
(see
ImplementationDocs/NarrativeAuthoringWorkflow.plan.md).
Quest Flow¶
Quest Lifecycle¶
Discovery Active Completed
│ │ │
▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌───────────┐
│ Trigger │ │ Progress │ │ Rewards │
│ • NPC │─────StartQuest()─▶│ • Tags │──CompleteQuest()─▶│ • Items │
│ • Item │ │ • Dialog │ │ • Consume │
│ • Zone │ │ │ │ Items │
└─────────┘ └──────────┘ └───────────┘
State Transitions¶
AddQuestTag(Tag) [authority only]
│
├─ Reject invalid / already-present tag
├─ Add to QuestTags container
├─ Broadcast OnQuestTagAdded
├─ QuestManager registry lookup: does Tag unlock a compendium entry?
│ └─ yes → CompendiumSystem->AddEntry + ShowCompendiumUnlock (diegetic: entry title alone)
├─ QuestManager deepen lookup: does Tag deepen an existing entry?
│ └─ yes → CompendiumSystem->DeepenEntry (content grows, entry flips back to unread — no toast)
└─ Replication (COND_OwnerOnly) → OnRep on owning client
StartQuest(QuestTag) [authority only]
│
├─ Quest must be registered in QuestManager
├─ Reject if already active or completed
├─ Add to ActiveQuests
└─ Broadcast OnQuestStarted
CompleteQuest(QuestTag) [authority only]
│
├─ Quest must be registered and active, not already completed
├─ Move ActiveQuests → CompletedQuests
├─ AddQuestTag(QuestTag + ".Completed")
├─ Broadcast OnQuestCompleted
├─ CheckForQuestItemConsumption (FQuestFragment items)
└─ GrantQuestRewards (item pipeline — see Quest Rewards)
Grant Paths (all converge on AddQuestTag)¶
| Path | Mechanism |
|---|---|
| Dialogue | UQuestDialogueEvent → Server RPCs on UPlayerQuestComponent |
| Item pickup/drop | FQuestFragment::OnPickup/OnDrop (bound to inventory events) |
| Lore actor | ALoreFragmentActor (LoreUnlockTag on examine) |
| World trigger | BP_StartQuestTrigger (Blueprint) |
| World hints | WorldHintComponent → ServerAddQuestTag |
Component Responsibilities¶
UQuestManagerComponent (Global)¶
Lives on AEternalGameState, server-authoritative.
| Method | Purpose |
|---|---|
LoadAllQuestDataAssets() |
Asset-registry scan at BeginPlay; builds all four registries (quests, entry unlocks, deepen stages, category display assets) |
RegisterQuest(QuestData) |
Add quest to registry (also indexes its compendium entries) |
GetQuestDataByTag(Tag) |
Lookup quest data asset |
GetCompendiumEntryByTag(RequiredTag) |
Fast entry lookup used by AddQuestTag |
GetCompendiumDeepenByTag(RequiredTag) |
Fast deepen-stage lookup used by AddQuestTag |
GetCompendiumCategoryByTag(CategoryTag) |
Display data (UCompendiumCategoryDataAsset) for a journal tile |
StartGlobalEvent(Tag) / EndGlobalEvent(Tag) |
Activate/deactivate world event |
IsGlobalEventActive(Tag) |
Check event status |
UPlayerQuestComponent (Per-Player)¶
Lives on AEternalPlayerState, server-authoritative. Every mutation early-returns unless
GetOwnerRole() == ROLE_Authority.
| Method | Purpose |
|---|---|
AddQuestTag(Tag) |
Add tag (authority) — the single grant funnel |
RemoveQuestTag(Tag) |
Remove tag (authority) |
StartQuest(Tag) / CompleteQuest(Tag) |
Quest lifecycle (authority) |
ServerAddQuestTag / ServerRemoveQuestTag / ServerStartQuest / ServerCompleteQuest |
Server RPC mirrors — the ONLY correct call surface from client-side code (dialogue, UI) |
HasQuestTag(Tag) |
Hierarchical check (see Gotchas) |
IsQuestActive(Tag) / IsQuestCompleted(Tag) |
Status queries |
GetQuestTags() / GetActiveQuestTags() / GetCompletedQuestTags() |
Container access |
Persistence goes through PersistenceHelpers / PersistenceComponent, not through this component — it has no
GetSaveData/LoadFromSaveData of its own (older doc claimed otherwise).
Quest Item Consumption¶
When a quest completes, the system checks inventory for items with FQuestFragment:
CompleteQuest(QuestTag)
│
└─ CheckForQuestItemConsumption(QuestTag)
│
├─ Iterate inventory FastArray entries
├─ Find items whose FQuestFragment.RelatedQuestTag matches
└─ Fragment's OnQuestCompleted handles consumption
Quest Data Model¶
UQuestDataAsset¶
| Property | Purpose |
|---|---|
QuestName |
Display name (also the journal tile title for legacy quest-tag-grouped entries) |
QuestDescription |
Full description text |
QuestBanner |
UI banner image |
QuestTag |
Unique identifier tag |
Objectives |
Array of objectives (display metadata for tags) |
Rewards |
Array of rewards, granted on CompleteQuest |
CompendiumEntries |
Lore entries unlocked during this quest (inline — ruled N3, no per-entry assets) |
CompendiumDeepenStages |
Deepen stages this quest's tags apply to already-unlocked entries (theirs or another quest's) |
The checklist-era fields (DiscoveryCondition, CompletionCondition, bIsRepeatable, bIsHidden,
RequiredGearScore, FQuestCondition, FQuestSaveData) were never read by anything and were deleted in the
2026-07 Phase 1 cleanup. Do not reintroduce.
FQuestObjective¶
| Property | Purpose |
|---|---|
DisplayName |
Short objective name |
Description |
Objective details |
CompletionTag |
The tag whose grant represents this objective |
bIsOptional |
Design metadata (nothing enforces it — tags are the truth) |
FQuestReward¶
| Property | Purpose |
|---|---|
DisplayName |
Reward display name (falls back to the item's name fragment if empty) |
Description |
Reward description |
ItemTemplate |
TSoftObjectPtr<UItemManifestDataAsset> to grant |
ItemAmount |
Quantity (stack count for stackables) |
Quest Rewards¶
CompleteQuest → GrantQuestRewards (server-side, PlayerQuestComponent.cpp):
- Load each reward's
ItemTemplatemanifest asset. - Copy the manifest, apply stack count, and run
UItemGenerationLibrary::GenerateItemProperties— the same generated-item flow as vendor stock, so gear rewards roll modifiers server-side. Manifest()theUItemObject(outer = owning PlayerController, matching the pickup path) andTryAddItemToContaineron the player's inventory.- Inventory full → the reward is world-dropped at the player's feet via
AEternalGameState::Server_RequestSpawnIteminstead of being lost. Client_ShowRewardNotificationtoast on the owning client.
Compendium System¶
UCompendiumSystem¶
Journal/lore system on the PlayerController (AEternalPlayer).
| Method | Purpose |
|---|---|
AddEntry(Entry) |
Add new compendium entry (stamps DiscoveryTime, dedups by EntryID) |
UpdateEntry(ID, Content) |
Replace entry text — always flips the entry back to unread |
DeepenEntry(Stage) |
Apply a deepen stage: compose (append/replace) onto existing content via UpdateEntry; refuses (warn) if the entry isn't unlocked yet |
MarkEntryAsRead(ID) / ServerMarkEntryAsRead(ID) |
Clear unread indicator (server RPC for client UI) |
GetEntry(ID) / GetAllEntries() |
Retrieval (chronological, oldest first — the list reads as a story) |
GetEntriesByQuestTag(Tag) |
Filter by quest (hierarchical tag match) |
GetUnreadEntries() / GetRecentEntries(Count) |
Filtered views |
GetSaveData() / LoadFromSaveData(Entries) |
Persistence surface (used by PersistenceHelpers) |
Entries is an FCompendiumEntryArray (FastArraySerializer) replicated COND_OwnerOnly (ruling N4, shipped) —
the owning client's journal live-updates via PostReplicatedAdd/Change/Receive callbacks. Note the OnRegister
OwnerComponent = this re-bind: without it, archetype property copy points the callbacks at the CDO and a
remote client's journal never updates.
FCompendiumEntry¶
| Property | Purpose |
|---|---|
EntryID |
Unique identifier (FName) |
Title |
Entry title |
Content |
Full text content (single FText — class-voiced variants deferred, ruling N6) |
RelatedQuestTag |
Authoring quest (provenance + grouping fallback) |
CategoryTag |
Player-facing journal tile (Compendium.Category.*, thematic/locational — "The Red Desert"). Unset → groups under RelatedQuestTag and titles from QuestName. Display data: UCompendiumCategoryDataAsset |
RequiredTag |
The tag whose grant unlocks this entry (exact match) |
DiscoveryTime |
When discovered (runtime, sort key — never rendered) |
bHasBeenRead |
Read state (runtime; also re-set false by deepening) |
FCompendiumDeepenStage¶
Deepening = the activity-safe forward pull: an already-unlocked entry gains content when a later tag lands, and
flips back to unread — the badge is the whole presentation (no toast, no counter, per the no-completion-metrics
pillar). Stages live on UQuestDataAsset.CompendiumDeepenStages and may target another quest's entry.
| Property | Purpose |
|---|---|
EntryID |
The unlocked entry to deepen |
RequiredTag |
Exact tag whose grant applies the stage |
Content |
New text |
bAppend |
Append below existing content (default) or replace outright |
Authoring flows through the quest manifest (deepen[] + categories[] sections —
Tools/Narrative/SPEC_FORMAT.md); the narrative validator checks deepen keys like unlock keys and errors on a
CategoryTag with no display asset.
Category Widget ViewModel Binding¶
UCompendiumController injects the ViewModel into each UCompendiumCategoryWidget at creation time
via SetViewModel. Injection before construction just stores the ViewModel; NativeConstruct performs the
binding. Looking the ViewModel up by widget name in the MVVM collection is now only a fallback for
Blueprint-placed instances, and every ViewModel dereference early-returns on null.
The name lookup could not stay the primary path: the collection is keyed by widget name, and a second world in the same GameInstance recreates the widget under a uniquified name. The lookup then misses and the tile silently renders unbound — a bug that only appears on the second session, never the first.
Red-Text Formatting¶
Compendium entry rich-text keyword formatting (red-text keywords etc.) lives entirely in asset data:
Content/DataAssets/Compendium/DefaultCompendiumFormattingRules.uasset, referenced by W_CompendiumEntry.
Adding a keyword is an asset edit, not a recompile — the old hardcoded C++ table in
CompendiumFormattingRules.cpp was removed (2026-07 Phase 1). If no rules asset is wired, content renders raw.
Dialogue Integration¶
The DlgSystem bridge is four thin classes under Quest/Dialogue/ (+ VendorDialogueEvent).
Walking Away Closes the Conversation¶
Movement is deliberately never blocked during dialogue (owner ruling: genre-standard PoE/Diablo feel). Walking
away therefore is the close gesture, and UDialogueController treats it as one.
A 0.25s timer poll — not a tick — measures distance to the speaker. Past WalkAwayEndDistance
(600, EditDefaultsOnly), or if the speaker despawns, it calls TryEndDialogue. The poll is cleared inside
TryEndDialogue, so it cannot outlive the conversation.
Cinematic dialogues (null instigator) never start the poll — there is no speaker to walk away from.
UQuestDialogueCondition¶
Gates dialogue nodes on quest state. Runs on the owning client against the replicated QuestTags container — safe because quest tags replicate owner-only.
Dialogue Node
│
└─ QuestDialogueCondition
├─ QuestTag: Quest.BeautifulFountain.Discovery.NPCMentioned
└─ bRequireTag: true (must have to show this dialogue)
UQuestDialogueEvent¶
Triggers quest actions from dialogue. Dialogue playback is client-side (UDialogueController runs the loop
on the interacting client; W_Dialogue renders it),
so the event routes through the Server RPCs — calling the authority-gated methods directly from a dialogue event
was a silent no-op on dedicated servers (fixed 2026-07, guarded by AQuestDialogueEventTest).
| Event Type | Routed To |
|---|---|
AddQuestTag |
ServerAddQuestTag |
RemoveQuestTag |
ServerRemoveQuestTag |
StartQuest |
ServerStartQuest |
CompleteQuest |
ServerCompleteQuest |
UQuestDialogueTextArgument¶
Dynamic text substitution in dialogue lines (quest name/description/objective text, compendium entry content).
IQuestParticipant¶
Quest/Interfaces/QuestParticipant.h — one method, GetPlayerQuestComponent(). Implemented by
AEternalPlayerState (the dialogue participant for the player side) and AEternalNPC.
Multiplayer¶
- Authority: every quest/compendium mutation is authority-gated; client-side systems (dialogue, UI, cheats)
must use the
Server*RPCs. - Replication:
QuestTags/ActiveQuests/CompletedQuestsreplicateCOND_OwnerOnly— other players never see your quest state. Global events replicate to everyone. - Compendium: server + persistence only today (see warning above; N4 fix scheduled).
- Trust surface: the Server RPCs perform no server-side plausibility validation (a modified client could grant itself any quest tag). Accepted for the current co-op trust model; revisit if that model changes.
- Tests:
AQuestStartTest,AQuestCompleteTest,AQuestDialogueEventTestinContent/Maps/FunctionalTests/ReplicationTestMap.umap(listen server + client PIE).
Public Contracts¶
Events (UPlayerQuestComponent)¶
| Delegate | Payload | When Fired |
|---|---|---|
OnQuestTagAdded |
FGameplayTag |
Tag added |
OnQuestTagRemoved |
FGameplayTag |
Tag removed |
OnQuestStarted |
FGameplayTag |
Quest begins |
OnQuestCompleted |
FGameplayTag |
Quest finishes |
Events (UQuestManagerComponent)¶
| Delegate | Payload | When Fired |
|---|---|---|
OnGlobalEventStarted |
FGameplayTag |
World event begins |
OnGlobalEventEnded |
FGameplayTag |
World event ends |
Events (UCompendiumSystem)¶
| Delegate | Payload | When Fired |
|---|---|---|
OnEntryAdded |
FCompendiumEntry |
New entry discovered |
OnEntryUpdated |
FCompendiumEntry |
Entry content changed |
OnEntryRead |
FCompendiumEntry |
Entry marked as read |
OnCompendiumReloaded |
(none) | Bulk reload after LoadFromSaveData |
Replication¶
| Property | Replication |
|---|---|
QuestTags |
ReplicatedUsing = OnRep_QuestTags, COND_OwnerOnly |
ActiveQuests |
ReplicatedUsing = OnRep_ActiveQuests, COND_OwnerOnly |
CompletedQuests |
ReplicatedUsing = OnRep_CompletedQuests, COND_OwnerOnly |
ActiveGlobalEvents |
ReplicatedUsing = OnRep_ActiveGlobalEvents |
Gotchas¶
HasQuestTagis hierarchical (FGameplayTagContainer::HasTag): holdingQuest.X.Discovery.Yanswers true forHasQuestTag(Quest.X). ConsequentlyAddQuestTag(Quest.X)no-ops if any child ofQuest.Xis already held (the duplicate check uses the same hierarchical match). Grant roots before children, or treat root tags as implied. The planned narrative validator will flag graphs that depend on grant-root-after-child ordering.- Completion tags are string-synthesized —
QuestTag + ".Completed"must exist inGameplayTags.inior the grant silently resolves to an invalid tag and is rejected. - Never call
AddQuestTag/StartQuest/CompleteQuestfrom client-side code — they silently no-op off-authority. Use theServer*RPCs.
Source References¶
| Component | Location |
|---|---|
UQuestManagerComponent |
Quest/QuestManagerComponent.h |
UPlayerQuestComponent |
Quest/PlayerQuestComponent.h |
UQuestDataAsset |
Quest/QuestDataAsset.h |
FQuestObjective, FQuestReward, FCompendiumEntry |
Quest/QuestTypes.h |
UCompendiumSystem |
Quest/CompendiumSystem.h |
UQuestDialogueCondition |
Quest/Dialogue/QuestDialogueCondition.h |
UQuestDialogueEvent |
Quest/Dialogue/QuestDialogueEvent.h |
UQuestDialogueTextArgument |
Quest/Dialogue/QuestDialogueTextArgument.h |
IQuestParticipant |
Quest/Interfaces/QuestParticipant.h |
FQuestFragment |
Inventory/Items/Fragments/ItemFragment.h |
UCompendiumFormattingRules |
Data/CompendiumFormattingRules.h |
| Replication tests | Tests/QuestReplicationTest.h + Private/Tests/Replication/QuestReplicationTest.cpp |
Related Systems¶
- Inventory System - Quest item storage, reward grants
- Item Fragments - FQuestFragment
- Item Generation - Reward modifier rolling
- World Systems - Global events
- MVVM Framework - Compendium UI
- Replication Overview - Quest state sync
ImplementationDocs/NarrativeAuthoringWorkflow.plan.md- Authoring pipeline (manifest → stamp → validate → overview)
Recent Changes¶
| Date | Change | Impact |
|---|---|---|
| 2026-08-06 | Compendium category widgets now receive their ViewModel by injection (SetViewModel) from UCompendiumController; MVVM name lookup demoted to a Blueprint-placed fallback; all ViewModel dereferences early-return on null |
Name-keyed lookup broke on a second world in the same GameInstance — the recreated widget's name is uniquified, so the tile rendered unbound on every session after the first |
| 2026-07-23 | Walk-away auto-close (21bedd104): a 0.25s timer poll ends the conversation past WalkAwayEndDistance (600) or on speaker despawn; cinematic dialogues never start the poll |
Confirms the ruling that movement is never blocked during dialogue — walking away is the close gesture, and it no longer leaves an orphaned dialogue window |
| 2026-07-07 | Phase 1 cleanup (NarrativeAuthoringWorkflow): vestige deletion, dialogue-event server RPC routing, reward granting wired, red-text rules moved to asset data, doc rewritten around tag-graph philosophy | Checklist-era fields gone; dedicated-server dialogue events fixed |
| - | Initial documentation | - |