SOL CONSTRUCT
Extended Contributions
- Custom Enemy ecosystem with air and ground units.
- Core player flight, built with a tonne of playtesting, removing disorienting axis to create an intuitive flight model, using a custom replicatable movement component
- A super optimised projectile system using the FLECS C++ library.
- Co-Designed Combat encounters, flow, and requirements, fully implemented in Blueprints.
- An Async Pathfinding and Utility EQS implementation for dynamic flight awareness.
- Developed an internal Plugin for procedural texture generation.
- Full enemy implementation, including behavior trees, modular skeleton frames, animation BPs, and health/armor components.
- Led art direction translation, screen space shader creation, and 3D art pipeline iterations.
Honorable Mentions:
- ▪ C++ projection vectors for various projectile shapes (Like Returnal's bullet hell!).
- ▪ Niagara integration rendering off raw FLECS ECS entity data.
- ▪ completely Material based Lidar Scanner XRay mechanic.
- ▪Procedural weapon recoil, accuracy and bloom.
- ▪ Automated target tracking and lock on crosshair, using a single widget to render dozens of screen space icons.
- ▪ Survival Gameplay loops incorporating harvestable world drops and furnace mechanics.
Overview
SOL CONSTRUCT is the culmination of nearly 3 years of professional game development, encompassing all the triumphs, failures, and harsh lessons learned along the way. After oiginally setting out to make a flying game (SOL DRIFT), we explored numerous genres before circling back to our roots, but this time with a hardened design philosophy.
As a small team, we had to evaluate every mechanic on a “Bang for Buck” basis. We realised early on that the highest degree of excitement and variation in our gameplay loop would come directly from our enemies. No matter our capacity for level design or weapon crafting, our core pillar became creating AI that dynamically exploits and challenges the player’s actions. The sandbox could then be balanced and built upon to get the most out of those encounters.
Persistent Quest Subsystem
Bespoke system for our Quests, left flexible enough to handle any challenges, achievements and secret quests too! This was a genuine relief to make - Unreal State Trees are just too unwieldly for the team’s needs, and didn’t allow for the Data-driven approach that we enjoy using. This solution allowed everyone to edit quests on the fly inside a custom graph editor, or in datatables, or even in excel!! I was heavily inspired by the Template and Instance design philosophy when tackling this one, and jinyuliao’s Generic Graph plugin made it incredibly easy to expose all this into a workable and digestible format.
The Graph has a simple button that allows designers to bake their nodes down into a csv format, which then populates an in-engine Datatable, which in turn fules the Subsystem that takes care of everything. You can take a peak at some of the Susbsystem code below!
QuestSubsystem.h
#pragma once
#include "CoreMinimal.h"
#include "flecs.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "GameplayTagContainer.h"
#include "Engine/DataTable.h"
#include "Engine/DataAsset.h"
#include "QuestSubsystem.generated.h"
class UQuestStep_Base;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnQuestStepStarted, FGameplayTag, QuestTag);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnQuestStepCompleted, FGameplayTag, QuestTag);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnGlobalQuestEvent, FGameplayTag, EventTag, int32, Amount);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnQuestItemCollected, UPrimaryDataAsset*, ItemData, FGameplayTag, ItemTag, int32, Amount);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnQuestInteractable, FGameplayTag, InteractTag, AActor*, Interactor);
USTRUCT(BlueprintType)
struct FRewardStruct
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Reward")
UPrimaryDataAsset* Item = nullptr;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Reward")
int32 Amount = 0;
};
UENUM(BlueprintType)
enum class EObjectiveType : uint8
{
Interact UMETA(DisplayName = "Interact"),
Kill UMETA(DisplayName = "Kill"),
Collect UMETA(DisplayName = "Collect"),
};
USTRUCT(BlueprintType)
struct FQuestObjective
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
FText ObjectiveDescription;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
EObjectiveType ObjectiveType = EObjectiveType::Interact;
// Used for Kill objectives, or general tag based tracking
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
FGameplayTag TargetTag;
// Only appears in the Data Table if 'Collect' is selected
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data", meta=(EditCondition="ObjectiveType == EObjectiveType::Collect", EditConditionHides))
UPrimaryDataAsset* TargetItemAsset = nullptr;
// Only appears in the Data Table if 'Interact' is selected
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data", meta=(EditCondition="ObjectiveType == EObjectiveType::Interact", EditConditionHides))
TSubclassOf<AActor> TargetActorClass;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
int32 RequiredAmount = 1;
};
USTRUCT(BlueprintType)
struct FQuestDatabase : public FTableRowBase
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
FText StepDescription;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
TArray<FQuestObjective> Objectives;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
FGameplayTag CurrentStepTag;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
FGameplayTagContainer PrerequisiteQuests;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
FGameplayTag NextStepTag;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
FGameplayTag QuestLineTag;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
TArray<FRewardStruct> Rewards;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Quest Data")
TSubclassOf<UQuestStep_Base> CustomStepLogic;
};
// Save state struct. NOT USED YET. Just making sure its here for future implementation
USTRUCT(BlueprintType)
struct FActiveQuestSaveData
{
GENERATED_BODY()
UPROPERTY(BlueprintReadWrite, Category = "Quest Save Data")
FGameplayTag QuestStepTag;
UPROPERTY(BlueprintReadWrite, Category = "Quest Save Data")
TArray<int32> ObjectiveProgress;
};
UCLASS()
class UQuestSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
// Map the Step Tag to the Instanced UObject managing it
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Quests|Active")
TMap<FGameplayTag, UQuestStep_Base*> ActiveQuestInstances;
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Quests|Active")
FGameplayTagContainer CompletedQuests;
UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category = "Quests|Active")
UDataTable* QuestDataTableAsset;
UFUNCTION(BlueprintCallable, Category = "Quests|Setup")
void InitializeQuestSystem(UDataTable* InDataTable);
UFUNCTION(BlueprintCallable, Category = "Quests")
void StartQuestStep(FGameplayTag StartingTag);
UFUNCTION(BlueprintCallable, Category = "Quests") // Called by UQuestStep_Base when it finishes
void FinishQuestStep(FGameplayTag StepTag);
UFUNCTION(BlueprintCallable, Category = "Quests|Save")
TArray<FActiveQuestSaveData> ExtractSaveData() const;
UFUNCTION(BlueprintCallable, Category = "Quests|Save")
void RestoreSaveData(const TArray<FActiveQuestSaveData>& SavedData);
UFUNCTION(BlueprintCallable, Category = "Quests|Broadcast")
void BroadcastGlobalQuestEvent(FGameplayTag EventTag, int32 Amount);
UFUNCTION(BlueprintCallable, Category = "Quests|Broadcast")
void BroadcastItemCollected(UPrimaryDataAsset* ItemData, FGameplayTag ItemTag, int32 Amount);
UFUNCTION(BlueprintCallable, Category = "Quests|Broadcast")
void BroadcastWorldInteractable(FGameplayTag InteractTag, AActor* Interactor);
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Quests|Queries")
bool IsQuestActive(FGameplayTag QuestTag) const;
UFUNCTION(BlueprintCallable, BlueprintPure, Category = "Quests|Queries")
bool IsQuestCompleted(FGameplayTag QuestTag) const;
UPROPERTY(BlueprintAssignable, Category = "Quests|Events")
FOnQuestStepStarted OnQuestStepStarted;
UPROPERTY(BlueprintAssignable, Category = "Quests|Events")
FOnQuestStepCompleted OnQuestStepCompleted;
UPROPERTY(BlueprintAssignable, Category= "Quests|Events")
FOnGlobalQuestEvent OnGlobalQuestEvent;
UPROPERTY(BlueprintAssignable, Category= "Quests|Events")
FOnQuestItemCollected OnQuestItemCollected;
UPROPERTY(BlueprintAssignable, Category= "Quests|Events")
FOnQuestInteractable OnQuestInteractable;
}; QuestSubsystem.cpp
#include "QuestSubsystem.h"
#include "QuestStep_Base.h"
void UQuestSubsystem::InitializeQuestSystem(UDataTable* InDataTable)
{
if (InDataTable != nullptr)
{
QuestDataTableAsset = InDataTable;
UE_LOG(LogTemp, Warning, TEXT("Quest System Initialised!"));
}
}
void UQuestSubsystem::StartQuestStep(FGameplayTag StartingTag)
{
if (QuestDataTableAsset == nullptr) return;
if (ActiveQuestInstances.Contains(StartingTag) || CompletedQuests.HasTagExact(StartingTag)) return;
FQuestDatabase* QuestRow = QuestDataTableAsset->FindRow<FQuestDatabase>(StartingTag.GetTagName(), TEXT("StartQuest Lookup"));
if (QuestRow != nullptr)
{
UE_LOG(LogTemp, Warning, TEXT("ATTEMPTING TO START NEXT QUEST STEP: %s "), *StartingTag.ToString());
// Determine Class to Spawn
TSubclassOf<UQuestStep_Base> ClassToSpawn = QuestRow->CustomStepLogic;
if (!ClassToSpawn)
{
// Automatically fall back to the default Blueprint class. not the C++ class!!!
ClassToSpawn = StaticLoadClass(UQuestStep_Base::StaticClass(), nullptr, TEXT("/Game/Quests/BP_QuestStep_Default.BP_QuestStep_Default_C"));
if (!ClassToSpawn)
{
UE_LOG(LogTemp, Error, TEXT("Failed to load default quest step BP. Was it moved or renamed?"));
return;
}
}
// Construct the Object
UQuestStep_Base* NewStep = NewObject<UQuestStep_Base>(this, ClassToSpawn);
// Add to Map & Initialize (which binds it to dispatchers)
ActiveQuestInstances.Add(StartingTag, NewStep);
NewStep->InitializeStep(*QuestRow, this);
OnQuestStepStarted.Broadcast(StartingTag);
}
}
void UQuestSubsystem::FinishQuestStep(FGameplayTag StepTag)
{
UE_LOG(LogTemp, Warning, TEXT("FINISHING QUEST STEP: %s "), *StepTag.ToString());
if (UQuestStep_Base** FoundStep = ActiveQuestInstances.Find(StepTag))
{
// find the row to check for NextStep
FQuestDatabase* QuestRow = QuestDataTableAsset->FindRow<FQuestDatabase>(StepTag.GetTagName(), TEXT("FinishQuest Lookup"));
if (QuestRow != nullptr)
{
// Grant Rewards Logic Here. NOT USED. Only if we plan on giving rewards in game sessions. Rewards are currently handled by the NPC's
if (QuestRow->Rewards.Num() > 0)
{
UE_LOG(LogTemp, Warning, TEXT("GRANT REWARDS (Logic TBD)"));
}
// Add to Completed Quests
CompletedQuests.AddTag(StepTag);
if (QuestRow->QuestLineTag.IsValid())
{
CompletedQuests.AddTag(QuestRow->QuestLineTag); // Add parent line to completed
}
// Clean up
ActiveQuestInstances.Remove(StepTag);
OnQuestStepCompleted.Broadcast(StepTag);
// Start next step if one exists
if (QuestRow->NextStepTag.IsValid())
{
StartQuestStep(QuestRow->NextStepTag);
}
}
}
}
// NOT USED YET. Some Temporary jargon for my own satisfaction (josh)
TArray<FActiveQuestSaveData> UQuestSubsystem::ExtractSaveData() const
{
TArray<FActiveQuestSaveData> SavedArray;
for (auto& Pair : ActiveQuestInstances)
{
if (Pair.Value)
{
SavedArray.Add(Pair.Value->GetSaveData());
}
}
return SavedArray;
}
// More temp placements
void UQuestSubsystem::RestoreSaveData(const TArray<FActiveQuestSaveData>& SavedData)
{
ActiveQuestInstances.Empty();
for (const FActiveQuestSaveData& Data : SavedData)
{
StartQuestStep(Data.QuestStepTag);
if (UQuestStep_Base** RestoredStep = ActiveQuestInstances.Find(Data.QuestStepTag))
{
(*RestoredStep)->RestoreFromSave(Data);
}
}
}
// BROADCASTS
void UQuestSubsystem::BroadcastGlobalQuestEvent(FGameplayTag EventTag, int32 Amount)
{
UE_LOG(LogTemp, Warning, TEXT("GLOBAL QUEST EVENT FIRED! EventTag: %s | Amount: %d "), *EventTag.ToString(), Amount);
OnGlobalQuestEvent.Broadcast(EventTag, Amount);
}
void UQuestSubsystem::BroadcastItemCollected(UPrimaryDataAsset* ItemData, FGameplayTag ItemTag, int32 Amount)
{
UE_LOG(LogTemp, Warning, TEXT("ITEM COLLECTED EVENT FIRED! ItemTag: %s | Amount: %d "), *ItemTag.ToString(), Amount);
OnQuestItemCollected.Broadcast(ItemData, ItemTag, Amount);
}
void UQuestSubsystem::BroadcastWorldInteractable(FGameplayTag InteractTag, AActor* Interactor)
{
UE_LOG(LogTemp, Warning, TEXT("WORLD INTERACT EVENT FIRED! InteractTag: %s "), *InteractTag.ToString());
OnQuestInteractable.Broadcast(InteractTag, Interactor);
}
// Helper functions
bool UQuestSubsystem::IsQuestActive(FGameplayTag QuestTag) const
{
return ActiveQuestInstances.Contains(QuestTag);
}
bool UQuestSubsystem::IsQuestCompleted(FGameplayTag QuestTag) const
{
return CompletedQuests.HasTag(QuestTag);
}
Beyond the perforance and organisation benefits of this solution, full customisation is still available through UObjects, allowing any quest step to inherit a totally unique object based off the parent class which just listens to broadcasts (like an enemy dying) offering total flexibility of when a player completes a quest, picks up an item or two, maybe spawn a boss if too many are collected, etc. A lot of our gameplay systems use Gameplay Tags, so creating a solution that fits the dev environment that we’re already used to was really important. This has led a near frictionless experience creating quests with the team.
I also implemented a debug menu for super fast quest testing and progression, as well as interation on those custom scenarios I mentioned earlier!
Designing a Flying Enemy Ecosystem
Designing flying enemies is a completely different beast compared to grounded AI. Because they have an entire Z axis of open space to utilise, restricting them and giving them purpose was my biggest design hurdle. To solve this, I modeled our enemy roster after chess pieces (definitely inspired by DOOM!), categorised by their effective engagement distances: Stationary, Long Range, Medium, and Close Range.






I also did the 3D modelling for these guys, trying to maintain as many reusable and modular parts.
My golden rule for designing and implementing every new enemy was asking: “What action am I trying to exploit from the player?”
For example:
- Long-range enemies are designed to encourage and exploit the player’s dash.
- Short-range enemies force the player to constantly reconsider their spatial positioning.
Here’s a snippet from one of our early enemies, capable of managing their own awareness (Sight, Noise, Health), Pathfinding and Attacks:
Because we always wanted a minimum level of enemy density in encounters, these units had to complement each other. They don’t necessarily “communicate” via code, but their roles naturally synergise. For example, I recently implemented a close range “Saw” enemy that embeds itself in the player, dealing tick damage but, more importantly, disabling the player’s dash and dodge. If left unchecked, this allows the Long Range sniper enemies (who usually miss a dashing player) to easily land devastating hits. This encourages the player to approach encounters strategically, prioritising targets based on the specific restrictions their synergy imposes.
Frankensteined Pathfinding & EQS
To bring these designs to life, the AI needed to understand 3D space. I frankensteined an existing pathfinding plugin combining Sparse Voxel Octrees with A* Pathfinding.
The Async Solution: Initially, synchronous pathfinding calls choked our Game Thread, causing massive stutters if multiple enemies requested paths simultaneously. By moving to an Async solution, I smoothed out the frame rate, carefully tuning the graph update rates to avoid garbage collection hitches.
EQS Implementation: I replaced our early, hacky target tracking with a much more robust Environment Query System (EQS). Enemies now transition through Patrolling, Searching, and Attacking states based on true Perception (hearing, damage, visibility). Using Dot product math, I programmed enemies to evaluate points based on the player’s orientation, allowing them to intelligently flank or close distance dynamically based on their role. This single feature completely blew our minds and gave us so much motivation to move forward.
EQS Medium Range Query breakdown
Volumetric grid Generation: The query generates a large 3D spherical grid of potential movement locations (a 3000-unit radius with 500-unit spacing), giving the flying AI a full 6 Degrees of Freedom (6DOF) spatial canvas to evaluate.
Environmental & Range Filtering: It utilizes a boolean Trace test to immediately cull any points obstructed by world geometry (ensuring a clear line of sight to the player) alongside a strict Distance filter to discard points outside the enemy’s effective combat range.
“Smart” Positioning via Dot Product: The remaining viable points are scored using an Inverse Linear Distance modifier and a Dot Product test. This mathematically evaluates points relative to the player’s facing direction, driving the AI to intelligently flank or prioritize specific angles of attack rather than simply flying straight at the target.
Iterating the Player Flight Model
During the first few months of development, player movement was the most heavily debated feature. I originally implemented Gyro Aim and full 6 Degrees of Freedom (6DOF). However, playtests revealed it was even more polarising than Marmite. Some players really grasped it, but many simply spun out. We realised that trying to cram standard flight sim conventions into an arcade action space was actively fighting the player’s intuition, at least in our game. Delivery Complete actually managed to implement it in a way that looks so cool, and frankly looks way more satisfying than what we had.
My solution was to strip it back. I removed roll and the ability to fly completely upside down, pivoting to a movement style akin to Minecraft’s creative mode or Interplay’s Descent. While it was tough to say goodbye to true 6DOF, this restriction immediately gave players total, intuitive control over their positioning. I matched this mechanically with a modular ship design. A central frame where wings, thrusters, and turrets attach, heavily utilising procedural animation to make the craft feel responsive and weighty. You can read more about the new Frame and Modules in my Blog post Creating the LFTR!
You can have a peep on what my anim graph looks like. Nothing too complex, but it adds a lot of juice:
Player Mesh Anim BP
Streamlining the Pipeline (ProcTex Plugin)
To get that crunchy aesthetic without bottlenecking production, I developed a custom Editor Utility Plugin. Rather than going in and out of Substance Painter for every asset, ProcTex allows us to down res, posterize, and manipulate textures with a real time 3D preview directly inside the Unreal Editor. This tool single handedly sped up our asset creation pipeline, allowing the team to generate game ready materials in minutes. You can find more deets on it here.



