Álvaro Gómez

GAME PROGRAMMING · UNREAL ENGINE 5 · C++ & BLUEPRINTS · SOLO

Programming Moon-Knight

A solo UE5 dark-fantasy RPG with every core system designed and built from scratch: combat, AI, persistence, and equipment.

C++ for handling the logic. Blueprints for handling the presentation.

Moon-Knight is a solo project, meaning I functioned as a designer and as an engineer and I had to code, debug and tune every aspect of the game. My approach for combining C++ and Unreal's Blueprints was a matter of timing and visuals. I used C++ for the states, lifetime, damage, and every logic system, while I used Blueprints for combo animations, trace windows or behaviour trees. The reasoning was the speed in development and what needed to be delivered. Below this are the decisions.

Technology

  • Unreal Engine 5
  • C++
  • Blueprint
  • Enhanced Input
  • Behaviour Trees
  • AI Perception
  • UMG
  • Data Tables
Moon-Knight-UE5-RPG on GitHub ↗

The division

What is in a Blueprint. What is in C++

I followed this rule for translating part of the code into C++: If something needed to exist, be tuned or feel right on a specific frame, I managed it in the Blueprints. If the level needed to load the system, it went go to C++. This is how the code architecture was distributed:

Which Moon-Knight systems are implemented in Blueprint and which in C++, and the class or asset that owns each.
SystemManaged inOwner
Combat Combo ChainMontage sequencing, sword trace and calling ApplyDamageBlueprintBPC_Attack System
Enemy BehaviourBehaviour Trees, Blackboard and Patrol TasksBlueprintBD_AI, BD_Werewolf
AI PerceptionDetection, Sight and Hearing Stimulus, Blackboard WritesC++AMKEnemyAIController
Inventory & EquipmentData Table Lookup, Equip Socket SwapBlueprintBPC_Equipment System
Player SystemsHealth, Combo State, Death and RespawnC++AMKPlayerCharacter
Session PersistenceWillow Tree Checkpoints, Equipped WeaponC++UMKGameInstance

The decisions

The Five Coding Choices That Shaped The Game

a · Architecture

The Hybrid Architecture

Decision

When I prototyped the game for the first time, I used Blueprints. Then I decided to transition part of the code to C++, so I had to make the Blueprints call down into C++, through BlueprintCallable; and C++ call up to a Blueprint using BlueprintImplementableEvent.

Why
  • One of the reasons for this was to reduce ambiguity when a bug appeared. If the wrong thing happened it was a C++ issue, however, if the right thing happened but looked weird, it was a Blueprint issue.
  • I could also tune animations quicker without having to compile the whole code every single time.
MKPlayerCharacter.hcpp
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "MoonKnightRPG.h"                    
#include "MKPlayerCharacter.generated.h" 

UCLASS()
class MOONKNIGHTRPG_API AMKPlayerCharacter : public ACharacter
{
    GENERATED_BODY()

public:
    AMKPlayerCharacter();

    virtual float TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent,
        AController* EventInstigator, AActor* DamageCauser) override;

    UFUNCTION(BlueprintCallable, Category = "Combat")
    void LightAttack();

    UFUNCTION(BlueprintCallable, Category = "Combat")
    void Dodge();

    UFUNCTION(BlueprintCallable, Category = "Combat")
    void StartParry();

    UFUNCTION(BlueprintCallable, Category = "Combat")
    void ResetCombatState();

    //--- NO HEALING IN COMBAT ---//
    UFUNCTION(BlueprintCallable, Category = "Health")
    bool TryHealth(float Amount);

    UFUNCTION(BlueprintPure, Category = "Health")
    bool IsInCombat() const { return bInCombat; }

    UFUNCTION(BlueprintPure, Category = "Health")
    float GetHealthPercent() const { return MaxHealth > 0.f ? CurrentHealth / MaxHealth : 0.f; }

protected:
    virtual void BeginPlay() override;

    UFUNCTION(BlueprintImplementableEvent, Category = "Death")
    void OnDeathFadeStart(float FadeDuration);

    UFUNCTION(BlueprintImplementableEvent, Category = "Combat")
    void OnComboAttack(int32 ComboIndex);

    void HandleDeath();
    void Respawn();

    //--- STATE ---//
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Health")
    float MaxHealth = 100.f;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Health")
    float CurrentHealth = 100.f;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Combat")
    ECombatState CombatState = ECombatState::Idle;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Combat")
    EWeaponType EquippedWeapon = EWeaponType::Sword;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Combat")
	  int32 ComboCounter = 0;

    UPROPERTY(EditAnywhere, Category = "Combat")
	  int32 MaxComboLength = 4;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Combat")
	  bool bInCombat = false;

    FTimerHandle RespawnTimerHandle;
    FTimerHandle ComboResetTimerHandle;
};

b · Tuning

Single Source for Tuning

Decision

The design constants were given a const in the MoonKnightConstants namespace. Also, the shared enums, ECombatState and EWeaponType, were defined once.

Why
  • Every constant variable is the exact number that was given in the GDD, so there wouldn't be any conflicts between the design and the code.
  • For a better tuning of values, I moved them to one file so I only had to look in one place.
  • The enums for the weapon types were defined once and shared, preventing conflicts between the Blueprints and the C++, making edits automatic and quick.
MoonKnightRPG.hcpp
//===================================================//
//------------------- GAME MODULE -------------------//
//===================================================//

#pragma once

#include "CoreMinimal.h"
#include "MoonKnightRPG.generated.h"
#include <cstdint>


UENUM(BlueprintType)
enum class ECombatState : uint8
{
    Idle            UMETA(DisplayName = "Idle"),
    Attacking       UMETA(DisplayName = "Attacking"),  
    Dodging         UMETA(DisplayName = "Dodging"),    
    Parrying	      UMETA(DisplayName = "Parrying"),
	  Staggered	      UMETA(DisplayName = "Staggered"),
    Dead            UMETA(DisplayName = "Dead")
};

ENUM(BlueprintType)
enum class EWeaponType : uint8
{
    Sword   UMETA(DisplayName = "Sword"),
    Bow     UMETA(DisplayName = "Bow")
};


namespace MoonKnightConstants 
{
    //--- Death and Respawn ---
    constexpr float DeathFadeDuration      = 5.0f;
    constexpr float RespawnDelay           = 4.9f;

    //--- Enemy Perception (sound and sight) ---
    constexpr float EnemySightRadius       = 1200.0f;
    constexpr float EnemySightAngleDegrees = 90.0f;
    constexpr float EnemyHearingRadius     = 800.0f;
    
    //--- Skill tree caps ---
    constexpr float MaxBowDamageBonus      = 100.0f;
}

c · Damage

The Damage Functions Inside the Engine

Decision

The health is subtracted inside the engine. All the damage is handled in the game, swords or enemies' attacks, just overrides TakeDamage.

Why
  • This puts all the system in one place. The parry negation and death are inside the TakeDamage, so any new damage source will just inherit both.
  • It keeps DamageCauser, EventInstigator and any other damage types available, keeping the code easy to read.
  • The parry was easy to test as the addition after the migration to C++. I could isolate the mechanic and tune it.
MKPlayerCharacter.cppcpp
float AMKPlayerCharacter::TakeDamage(float DamageAmount, FDamageEvent const& DamageEvent,
    AController* EventInstigator, AActor* DamageCauser)
{
    if (CombatState == ECombatState::Dead)
    {
        return 0.f;
    }

    //==================================//
    //--- PARRY TO HUMANOIDS ENEMIES ---//  
    //==================================//
    if (CombatState == ECombatState::Parrying)
    {
        return 0.f;
    }

    const float Applied = Super::TakeDamage(DamageAmount, DamageEvent, EventInstigator, DamageCauser);
    CurrentHealth = FMath::Clamp(CurrentHealth - Applied, 0.f, MaxHealth);
    bInCombat = true;

    if (CurrentHealth <= 0.f)
    {
        HandleDeath();
    }
    return Applied;
}

d · AI

Enemy Perception in C++

Decision

The AI controller sets the sight and hearing stimulus in C++, writing Blackboard keys like TargetActor, InvestigateLocation. I just needed to change from the Behaviour Tree.

Why
  • The perception had so many things that needed to be tuned and C++ was the right decision. Things like radii, peripheral angle, affiliation or stimulus age are numerical and shared and sometimes needed to be the same for different enemies.
  • The design side was already decided, so I didn't need to reorder selectors or add investigate branches. That could just happen in the Behaviour Trees.
  • The Blackboard is the perfect connection between these two. The C++ code only stated facts and the Blueprint decided what to do in those moments, so it just meant a matter of handling what to do and the order in those cases.
MKEnemyAIController.cppcpp
#include "MKEnemyAIController.h"
#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISenseConfig_Sight.h"
#include "Perception/AISenseConfig_Hearing.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "MKPlayerCharacter.h"

const FName AMKEnemyAIController::TargetActorKey(TEXT("TargetActor"));
const FName AMKEnemyAIController::InvestigateLocationKey(TEXT("InvestigateLocation"));

AMKEnemyAIController::AMKEnemyAIController()
{
    PerceptionComp = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("PerceptionComp"));

//===================================================//
//----------------- SIGHT DETECTION -----------------//
//===================================================//
    SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));
    SightConfig->SightRadius = MoonKnightConstants::EnemySightRadius;
    SightConfig->LoseSightRadius = MoonKnightConstants::EnemySightRadius * 1.25f;
    SightConfig->PeripheralVisionAngleDegrees = MoonKnightConstants::EnemySightAngleDegrees;
    SightConfig->DetectionByAffiliation.bDetectEnemies = true;
    SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
    SightConfig->DetectionByAffiliation.bDetectFriendlies = false;

//===================================================//
//----------------- HEAR DETECTION ------------------//
//===================================================//
    HearingConfig = CreateDefaultSubobject<UAISenseConfig_Hearing>(TEXT("HearingConfig"));
    HearingConfig->HearingRange = MoonKnightConstants::EnemyHearingRadius;
    HearingConfig->DetectionByAffiliation.bDetectEnemies = true;
    HearingConfig->DetectionByAffiliation.bDetectNeutrals = true;

    PerceptionComp->ConfigureSense(*SightConfig);
    PerceptionComp->ConfigureSense(*HearingConfig);
    PerceptionComp->SetDominantSense(SightConfig->GetSenseImplementation());

    PerceptionComp->OnTargetPerceptionUpdated.AddDynamic(
        this, &AMKEnemyAIController::OnTargetPerceptionUpdated);
}

void AMKEnemyAIController::OnPossess(APawn* InPawn)
{
    Super::OnPossess(InPawn);

    if (BehaviorTreeAsset)
    {
        RunBehaviorTree(BehaviorTreeAsset);
    }
}

void AMKEnemyAIController::OnTargetPerceptionUpdated(AActor* Actor, FAIStimulus Stimulus)
{
    UBlackboardComponent* BB = GetBlackboardComponent();
    if(!BB)
    {
        return;
    }


    if (AMKPlayerCharacter* Player = Cast<AMKPlayerCharacter>(Actor))
    {
        if (Stimulus.WasSuccessfullySensed())
        {
            BB->SetValueAsObject(TargetActorKey, Actor);
            BB->SetValueAsVector(InvestigateLocationKey, Stimulus.StimulusLocation);
        }
        return;
    }

    if (Stimulus.WasSuccessfullySensed())
    {
        BB->SetValueAsVector(InvestigateLocationKey, Stimulus.StimulusLocation);
    }
}

e · Persistence

The Data Should Last

Decision

Anything that had to be longer than the actor was present in the Game Instance. It was guaranteed to exist the whole time.

Why
  • There were some things that needed to be destroyed eventually, like the character or the levels. These things relied on the 3D objects.
  • The Willow Tree's checkpoint and the inventory had to survive death and respawn, so I placed them in UMKGameInstance.
  • This was actually a bug I couldn't fix for a long time. The equipment system disappeared when the character died. I tried to add this to BeginPlay, but the better option was to ask which object was supposed to own that state.
MKGameInstance.hcpp
#pragma once

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "MKPlayerCharacter.h"
#include "MoonKnightRPG.h"
#include "MKGameInstance.generated.h"

class AMKPlayerCharacter;

UCLASS()
class MOONKNIGHTRPG_API UMKGameInstance : public UGameInstance
{
    GENERATED_BODY()

public:
//------------- WILLOW TREE CHECKPOINTS -------------//
    UFUNCTION(BlueprintCallable, Category = "Checkpoint")
    void RegisterWillowTree(const FVector& TreeLocation);

    UFUNCTION(BlueprintCallable, Category = "Checkpoint")
    void RespawnAtLastWillowTree(AMKPlayerCharacter* Player);

    UFUNCTION(BlueprintPure, Category = "Checkpoint")
    bool HasCheckpoint() const { return bHasCheckpoint; }

//--------------- EQUIPMENT PERSISTENCE ---------------//
    UFUNCTION(BlueprintCallable, Category = "Equipment")
    void SaveEquippedWeapon(EWeaponType Weapon) { SavedWeapon = Weapon; }

    UFUNCTION(BlueprintPure, Category = "Equipment")
    EWeaponType GetSavedWeapon() const { return SavedWeapon; }

protected:
    UPROPERTY(VisibleAnywhere, Category = "Checkpoint")
    FVector LastWillowTreeLocation = FVector::ZeroVector;

    UPROPERTY(VisibleAnywhere, Category = "Checkpoint")
    bool bHasCheckpoint = false;

    UPROPERTY(VisibleAnywhere, Category = "Equipment")
    EWeaponType SavedWeapon = EWeaponType::Sword;
};

System deep-dive

The Combat State Machine

Moon-Knight has no block. Defence is a parry or a dodge, which is what makes the gameplay aggressive and fast-paced. That design can be seen in the combat state machine as there is no defensive state.

  • IdleFree movement. It accepts every input.Transitions to: Attacking · Dodging · Parrying
  • AttackingUp to a 4-hit combo chain. ComboCounter advances the animations and an auto-reset timer returns to Idle when it gets to last move.Transitions to: Attacking (next hit) · Idle (timer lapse)
  • DodgingA roll with invulnerability frames. Cannot be cancelled into an attack.Transitions to: Idle
  • ParryingA narrow timed window. A hit landing inside it is negated outright in TakeDamage.Transitions to: Idle · Staggered (missed window)
  • StaggeredThe cost of TakeDamage. The input is locked until the recovery finishes.Transitions to: Idle · Dead
  • DeadThe death handling. The Game Instance checks the last checkpoint and the player respawns there.Transitions to: Idle (respawn at last Willow Tree)
This is the Animation Montage of the first attack. The space between the two notifies of the animation is the time that the player has to concatenate attacks. If the player presses the input outside of this time the attack combo will fail. This is the connection between the animation and the Blueprints.
MKPlayerCharacter.cppcpp
void AMKPlayerCharacter::LightAttack()
{
    if (CombatState == ECombatState::Dead || CombatState == ECombatState::Staggered)
    {
        return;
    }

    //===================================//
    //---------- ADVANCE COMBO ---------//
    //==================================//
    ComboCounter = (ComboCounter % MaxComboLength) + 1;
    CombatState = ECombatState::Attacking;
    bInCombat = true;
    OnComboAttack(ComboCounter);

    //=========================================//
    //--- RESET THE COMBO WITHOUT FOLLOW-UP ---//
    //=========================================//
    GetWorldTimerManager().SetTimer(ComboResetTimerHandle, this,
        &AMKPlayerCharacter::ResetCombatState, 1.2f, false);
}

void AMKPlayerCharacter::Dodge()
{
    if (CombatState == ECombatState::Dead)
    {
        return;
    }

    //=========================================//
    //------------ NO STAMINA LOSS ------------//
    //=========================================//
    CombatState = ECombatState::Dodging;
}

void AMKPlayerCharacter::StartParry()
{
    if (CombatState == ECombatState::Idle || CombatState == ECombatState::Dodging)
    {
        CombatState = ECombatState::Parrying;
    }
}

void AMKPlayerCharacter::ResetCombatState()
{
	if (CombatState != ECombatState::Dead)
	{
		CombatState = ECombatState::Idle;
		ComboCounter = 0;
	}
}

void AMKPlayerCharacter::HandleDeath()
{
    CombatState = ECombatState::Dead;
    DisableInput(Cast<APlayerController>(GetController()));

    OnDeathFadeStart(MoonKnightConstants::DeathFadeDuration);

    GetWorldTimerManager().SetTimer(RespawnTimerHandle, this,
        &AMKPlayerCharacter::Respawn, MoonKnightConstants::RespawnDelay, false);
}


//=========================================//
//---------- WILLOW TREE RESPAWN ----------//
//=========================================//
void AMKPlayerCharacter::Respawn()
{
    if (UMKGameInstance* GI = Cast<UMKGameInstance>(UGameplayStatics::GetGameInstance(this)))
    {
        GI->RespawnAtLastWillowTree(this);
    }
    CurrentHealth = MaxHealth;
    ResetCombatState();
    EnableInput(Cast<APlayerController>(GetController()));
}

From the editor

The Blueprints

These are the Blueprints that I used for the prototype. Some of them are the old blueprints that were eventually moved to the C++, like the combat combo and the sword trace. I built them first in Blueprints to have the build ready firstly and I decided to code them in the C++ later. However, what remains as a Blueprint are the Behaviour Trees, the generation and the tuning. Click to read in full size.

  • Behaviour Tree · Standard Enemy. Patrol / Investigate / Chase selector. It reads the TargetActor and InvestigateLocation that the C++ controller writes. Showing this as a Blueprint because it is easier to understand as a graph.
  • Behaviour Tree · Werewolf. The boss class. Same Blackboard with a different decision structure and a more aggressive chase decorator, which is the point of keeping the contract in C++ and the decisions in the tree.
  • Combo chain · BPC_Attack System. The 4-hit montage sequencing and the combo-continuation gate. Prototyped here and then reimplemented in C++ so the ComboCounter is handled in code and the window timing survives the level loading.
  • Sword Trace & ApplyDamage. The weapon trace during the active frames of the swings, a sphere radius of 12, a base damage of 20 with a Damageable tag that comes into the damage system. The trace stayed as a Blueprint before I translated it into C++ once the numbers stopped changing.
  • Target Lock. The lock is related to the camera, making it a 200-unit sphere trace, so it will never miss. This will lock to PhysicsBody and Pawn bodies.
  • WB_Equipment · The Equipment Screen. The conventional menu in the game, as in the end, due to the lack of time, I had to create a menu so the players could interact even though the design document seeks a diegetic design. This menu needed a grid for understanding the equipment system. It was built in UMG against the DB_Items data table, so every new item is a row and not a widget.
  • PCG Forest · The Level's Ground Cover. The three objects are rocks, trees and grass, molding the terrain and differentiating them so it wouldn't spawn inside anything else. This graph is related to the level design section stating that the forest is generated with a purpose but not randomised.It was made in Blueprints because nobody would want to tune this in code having Blueprints.

Where to next