Skip to content

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. UQuestManagerComponent on GameState holds the global registry; UPlayerQuestComponent on PlayerState tracks per-player tags. Dialogue (DlgSystem), item pickups, lore actors, and world triggers all funnel into the same server-authoritative AddQuestTag path.

Table of Contents


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 RequiredTag is 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 UQuestDialogueCondition rather 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
  • CompleteQuest synthesizes 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. Generic Quest.Stage.* / Quest.SubEvent.* scaffolding tags and the GetQuestDiscoveryTag/GetQuestCompletionTag helpers 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 WorldHintComponentServerAddQuestTag

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

CompleteQuestGrantQuestRewards (server-side, PlayerQuestComponent.cpp):

  1. Load each reward's ItemTemplate manifest asset.
  2. 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.
  3. Manifest() the UItemObject (outer = owning PlayerController, matching the pickup path) and TryAddItemToContainer on the player's inventory.
  4. Inventory full → the reward is world-dropped at the player's feet via AEternalGameState::Server_RequestSpawnItem instead of being lost.
  5. Client_ShowRewardNotification toast 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 / CompletedQuests replicate COND_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, AQuestDialogueEventTest in Content/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

  • HasQuestTag is hierarchical (FGameplayTagContainer::HasTag): holding Quest.X.Discovery.Y answers true for HasQuestTag(Quest.X). Consequently AddQuestTag(Quest.X) no-ops if any child of Quest.X is 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-synthesizedQuestTag + ".Completed" must exist in GameplayTags.ini or the grant silently resolves to an invalid tag and is rejected.
  • Never call AddQuestTag/StartQuest/CompleteQuest from client-side code — they silently no-op off-authority. Use the Server* 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


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 -