Lymbo
9 4 weeks 2025-09-05Lymbo is a surrealist exploration game with PSX-visuals. For this project I worked on the Dialogue and Quest Systems.
C++, Unreal Engine, Dialogue System, Quest System
The project where I felt Unreal just clicked. For this project I mainly worked on the branching Dialogue System and Quest System alongside furthering the development of an EventBus implementation from a previous project. This being a shorter project and being responsible for two major systems has shown me the importance of good planning and asking others for help when you need it.
After our initial sprint planning the top-priority task was deemed to get the Dialogue System in working order so designers could start prototyping dialogue as soon as possible. I first began by researching similar implementations whereupon I came across Yarn Spinner which is a dialogue scripting language. It works using a very simple scripting language with variables, conditional logic (if-statements) and jump statements to control dialogue flow. Given more time, I would definitely have liked to implement a script-parser for YarnSpinner in order to leverage the already huge library of ready-made tools for both graph and text-based editors. Ultimately after much discussion among the programmers and in consideration of the overall scope and length of the project we decided against implementing Yarn Spinner due to the high upfront cost.
Instead we decided to roll our own Dialogue System trying to leverage the built-in ways of storing data using DataAssets. I decided on using DataAssets over a DataRow in a DataTable mainly due to the modularity it would give the project. Because we were still at a very early stage in the project I wanted to make a system that was easily extensible in case the specifications were to change. Using a polymorphically stored DialogueNode in a DataAsset makes adding a new type of dialogue node trivial as a class simply has to inherit from the DialogueNode base-class and override the ExecuteDialogue(). This meant whatever function we needed to add, whether it be playing animations, cutscenes, giving an item to the player. It could easily be done through the DialogueNode. In contrast a DataTable backed by a CSV file would require adding a separate column for every single feature, leading to a lot of empty columns and increased risk of user-error.
#pragma once
#include "CoreMinimal.h"
#include "Systems/Dialogue/DialogueResult/FDialogueResult.h"
#include "UObject/Interface.h"
#include "DialogueSystemInterface.generated.h"
class UDialogueDataAsset;
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDialogueSet);
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDialogueInitiated);
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnDialogueProgressed, FDialogueResult, DialogueResult);
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnDialogueExited);
// This class does not need to be modified.
UINTERFACE()
class UDialogueSystemInterface : public UInterface
{
GENERATED_BODY()
};
class GP4_LYMBO_API IDialogueSystemInterface
{
GENERATED_BODY()
public:
virtual void InitiateDialogue(UDialogueDataAsset* SomeDialogueAsset, APlayerController* PlayerController) = 0;
virtual FDialogueResult ProgressDialogue() = 0;
virtual void ExitDialogue() = 0;
public:
virtual void SelectOption(int OptionIndex) = 0;
public:
virtual FOnDialogueSet& GetOnDialogueSetHandle() = 0;
virtual FOnDialogueInitiated& GetOnDialogueInitiatedHandle() = 0;
virtual FOnDialogueProgressed& GetOnDialogueProgressedHandle() = 0;
virtual FOnDialogueExited& GetOnDialogueExitedHandle() = 0;
};
#pragma once
#include "CoreMinimal.h"
#include "DialogueSystemInterface.h"
#include "Systems/Dialogue/DataAsset/DialogueDataAsset.h"
#include "Systems/Dialogue/DialogueContext/GameDialogueContext.h"
#include "UObject/Object.h"
#include "DialogueSystem.generated.h"
UCLASS()
class GP4_LYMBO_API UDialogueSystem : public UObject, public IDialogueSystemInterface
{
GENERATED_BODY()
public:
void SetDialogueDataSystem(TScriptInterface<IDialogueDataInterface> SomeDialogueDataSystem);
public:
void ResetDialogueSystem();
public:
virtual void InitiateDialogue(UDialogueDataAsset* SomeDialogueAsset, APlayerController* PlayerController) override;
virtual FDialogueResult ProgressDialogue() override;
virtual void ExitDialogue() override;
public:
virtual void SelectOption(int OptionIndex) override;
public:
virtual FOnDialogueSet& GetOnDialogueSetHandle() override;
virtual FOnDialogueInitiated& GetOnDialogueInitiatedHandle() override;
virtual FOnDialogueProgressed& GetOnDialogueProgressedHandle() override;
virtual FOnDialogueExited& GetOnDialogueExitedHandle() override;
public:
UPROPERTY(BlueprintAssignable, Category="Dialogue/Events")
FOnDialogueSet OnDialogueSet;
UPROPERTY(BlueprintAssignable, Category="Dialogue/Events")
FOnDialogueInitiated OnDialogueInitiated;
UPROPERTY(BlueprintAssignable, Category="Dialogue/Events")
FOnDialogueProgressed OnDialogueProgressed;
UPROPERTY(BlueprintAssignable, Category="Dialogue/Events")
FOnDialogueExited OnDialogueExited;
private:
UPROPERTY()
TScriptInterface<IDialogueDataInterface> DialogueDataSystem;
private:
FGameDialogueContext DialogueContext;
private:
UPROPERTY()
TObjectPtr<UDialogueDataAsset> DialogueAsset;
int CurrentNodeIndex = 0;
TMap<FString, int> LabelToIndexMap;
private:
UPROPERTY()
TMap<TObjectPtr<UDialogueDataAsset>, int> PreviousDialoguesIndicesMap;
private:
void SetUpLabels();
};
#pragma once
#include "CoreMinimal.h"
#include "Systems/Dialogue/DialogueContext/GameDialogueContext.h"
#include "Systems/Dialogue/DialogueResult/FDialogueResult.h"
#include "UObject/Object.h"
#include "DialogueNodeBase.generated.h"
UCLASS(Abstract, EditInlineNew)
class GP4_LYMBO_API UDialogueNodeBase : public UObject
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Dialogue")
TOptional<FString> Label;
public:
virtual bool CanExecute(const FGameDialogueContext& DialogueContext) const { return true; }
virtual FDialogueResult GetResult(const FGameDialogueContext& DialogueContext) PURE_VIRTUAL(
FDialogueResult, return FDialogueResult(););
virtual void PickOption(const FGameDialogueContext& DialogueContext, int OptionIndex) PURE_VIRTUAL();
virtual void ExecuteNode(FGameDialogueContext DialogueContext) { }
};
#pragma once
#include "CoreMinimal.h"
#include "DialogueNodeBase.h"
#include "GameplayTagContainer.h"
#include "StatementNode.generated.h"
UCLASS()
class GP4_LYMBO_API UStatementNode : public UDialogueNodeBase
{
GENERATED_BODY()
protected:
UPROPERTY(EditDefaultsOnly, Category = "Dialogue", meta = (Categories = "Speaker"))
FGameplayTag SpeakerTag = FGameplayTag::EmptyTag;
UPROPERTY(EditDefaultsOnly, Category = "Dialogue")
FText Text = FText::GetEmpty();
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Dialogue")
TArray<FDialogueAnimationData> Animations;
UPROPERTY(EditDefaultsOnly, Category = "Dialogue")
bool DoCloseDialogue = true;
virtual FDialogueResult GetResult(const FGameDialogueContext& DialogueContext) override;
};
#pragma once
#include "CoreMinimal.h"
#include "DialogueNodeBase.h"
#include "OptionNode.generated.h"
USTRUCT(BlueprintType)
struct FDialogueOption
{
GENERATED_BODY()
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Dialogue/Options")
FText Text = FText::GetEmpty();
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Instanced, Category = "Dialogue/Options")
TObjectPtr<UDialogueNodeBase> OnChosen = nullptr;
};
UCLASS()
class GP4_LYMBO_API UOptionNode : public UDialogueNodeBase
{
GENERATED_BODY()
public:
UPROPERTY(EditDefaultsOnly, Category = "Dialogue")
TArray<FDialogueOption> Options;
public:
virtual FDialogueResult GetResult(const FGameDialogueContext& DialogueContext) override;
virtual void PickOption(const FGameDialogueContext& DialogueContext, int OptionIndex) override;
};
One downside we found from the DataAsset approach was that the sheer amount of DialogueNodes in the DataAsset was very hard to navigate using the built-in UE Editor. As the number of nodes and jump statements in the node grew they became quite hard to work with and lacking in an easy way to look-up a specific line traversing them became quite problematic. This could be remedied in many ways. As mentioned earlier, in a dialogue heavy game implementing something like a custom YarnSpinner would probably be optimal if it weren’t for the initial time and technical complexity. Another choice would be to still roll a custom dialogue system solution by using DataRows, in DataRows each line can be given a unique identifier and store events to broadcast, animations to play, conditions for going to the next line etc. This would make it possible to keep all dialogue in one script for easy localization but would ultimately end up with many of the similar issues of being hard to traverse as the project and amount of dialogue lines grow. However due to DataRows easy serialization to CSV they could be edited externally which means a writer wouldn’t ever have to open Unreal to work and that they could do it in their preferred program.

While they are completely separate systems the quest system and dialogue system do share similarities at their core. Quests are implemented using a list of QuestConditions that contain information and different predicates in order to pass a quest. These are stored in a DataAsset and can be given to the player through a simple InitiateQuest function which passes an instigator along with the QuestData. Upon activation a UDelegate broadcast is made to allow for any UI and NPCs to react to new quest progression. Conditions can be evaluated both linearly with one quest leading to another or with multiple conditions active at the same time. When a quest is completed another delegate is broadcasted and the Quests state is set to finished in the Progression Manager.
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
#include "Quest/QuestDataAsset.h"
#include "UObject/Interface.h"
#include "QuestInterface.generated.h"
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuestCompleted);
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuestUpdated);
// This class does not need to be modified.
UINTERFACE(NotBlueprintable)
class UQuestInterface : public UInterface
{
GENERATED_BODY()
};
/**
*
*/
class GP4_LYMBO_API IQuestInterface
{
GENERATED_BODY()
public:
virtual void InitQuest(const AController* QuestInstigator, const UQuestDataAsset* QuestData){}
virtual void ExitQuest(){}
public:
UFUNCTION(BlueprintCallable)
virtual FString GetQuestName() const = 0;
UFUNCTION(BlueprintCallable)
virtual FGameplayTag GetQuestStartedTag() const = 0;
UFUNCTION(BlueprintCallable)
virtual FGameplayTag GetQuestFinishedTag() const = 0;
public:
UFUNCTION(BlueprintCallable)
virtual int GetNrOfCompletions() const = 0;
UFUNCTION(BlueprintCallable)
virtual int GetNrOfConditions() const = 0;
public:
UFUNCTION(BlueprintCallable)
virtual FString GetConditionDescription(int ConditionIndex) const = 0;
public:
virtual FOnQuestCompleted& GetOnQuestCompletedHandle() = 0;
virtual FOnQuestUpdated& GetOnQuestUpdatedHandle() = 0;
};
#pragma once
#include "CoreMinimal.h"
#include "QuestInterface.h"
#include "Quest/QuestDataAsset.h"
#include "QuestSystemInterface.generated.h"
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuestSystemCompleted);
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE(FOnQuestSystemUpdated);
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnQuestSystemChanged, TScriptInterface<IQuestInterface>, NewQuest);
// This class does not need to be modified.
UINTERFACE(NotBlueprintable)
class UQuestSystemInterface : public UInterface
{
GENERATED_BODY()
};
class GP4_LYMBO_API IQuestSystemInterface
{
GENERATED_BODY()
public:
///
/// @param QuestInstigator The controller that instigated the quest. Most likely the player controller.
/// @param QuestData The quest to set.
///
UFUNCTION(BlueprintCallable)
virtual void SetQuest(const AController* QuestInstigator, const UQuestDataAsset* QuestData) = 0;
// TODO: Does TScriptInterface support const types?
UFUNCTION(BlueprintCallable)
virtual TScriptInterface<IQuestInterface> GetQuest() const = 0;
public:
virtual FOnQuestSystemCompleted& GetOnQuestSystemCompletedHandle() = 0;
virtual FOnQuestSystemUpdated& GetOnQuestSystemUpdatedHandle() = 0;
virtual FOnQuestSystemChanged& GetOnQuestSystemChangedHandle() = 0;
};
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "QuestConditionInterface.generated.h"
UDELEGATE()
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnQuestConditionMet, TScriptInterface<IQuestConditionInterface>, ConditionMet);
// This class does not need to be modified.
UINTERFACE()
class UQuestConditionInterface : public UInterface
{
GENERATED_BODY()
};
class GP4_LYMBO_API IQuestConditionInterface
{
GENERATED_BODY()
// Called when condition is started/exited.
public:
UFUNCTION(BlueprintNativeEvent)
void InitCondition(const AController* QuestInstigator = nullptr);
UFUNCTION(BlueprintNativeEvent)
void ExitCondition();
public:
virtual FString GetConditionName() const = 0;
virtual FOnQuestConditionMet& GetOnConditionMetHandle() = 0;
};
In retrospect this project has taught me a lot about separation of data and gameplay along with how early decisions can lead to huge technical-debt and therefore impact development time both negatively and positively. So while I definitely have improvements I would have liked to make given the time overall I am very satisfied with the end-product and am satisfied with the work done by me and my teammates.