APPLY, DON'T JUST READ

Capstone Project: one character class through the entire course

The "Practice" assignment in each article tests one idea in isolation — enough to understand the idea, but not enough to feel how a dozen such ideas coexist in a single living class, where decisions from previous weeks cannot be quietly rewritten if today's topic doesn't suit them. This project is the second, parallel track: one class AAcademyCharacter in a small survival game, which the reader carries from Level 2 ("Runtime Objects") to Level 8 ("Production"), adding exactly the capability each new Level provides at each step — and nothing in advance.

The three rules of the project

1. The class is the same from start to finish. Not "a new example for every topic," but the same AAcademyCharacter (plus companion classes — components, controller, GameMode, etc.) that grows as the course progresses — just as a real project grows in development. Below are 12 steps — 00…11 — not 12 different games.

2. Earlier decisions are not rewritten silently. If the topic of the next step suggests a different approach to what was done earlier, this is documented as an explicit refactoring with a "Before / After" block explaining what changed and why, not as a silent replacement. In a real project, undoing a past decision without explanation is not an option.

3. Each step is a decision, not copying an example. The course articles provide UDataAsset, UHealthComponent, UGameplayAbility, and dozens of other code snippets as illustrations for their topics. Here the task is not to copy a snippet, but to decide exactly how it fits into an already existing class, what needs to be renamed, generalized, or tailored to the specific task of this project.

The layout below follows strictly by steps of the same game — in the order in which the course Levels provide the material for them. Each card represents what AAcademyCharacter (or its environment) can do after this step and couldn't before: which articles are needed for it, the specific list of results, and — where a step modifies an already existing file rather than creating a new one from scratch — a "Before / After" block with the actual code diff.

00
Step 00 — The character is born (Level 2)
The first line of code for the project — and it's deliberately boring. AAcademyCharacter appears as a bare inheritor of ACharacter: no health, no inventory, not a single gameplay capability. The collision capsule, skeletal mesh, and UCharacterMovementComponent are already there — they were created by ACharacter's constructor, and can only be modified through the existing subobjects, not recreated (see the article on the Actor lifecycle — why mandatory subobjects are created strictly in the constructor via CreateDefaultSubobject, not later). The goal of this step is not to rush: before adding capabilities, establish what in this class is a gameplay decision, and what is simply inherited from the engine for free.
AAcademyCharacter.hC++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();
};
AAcademyCharacter.cppC++
AAcademyCharacter::AAcademyCharacter()
{
    // Capsule and Mesh already exist — they were created by ACharacter's constructor.
    // We only touch what actually distinguishes our character from the default one.
    GetCapsuleComponent()->InitCapsuleSize(42.f, 96.f);
    bUseControllerRotationYaw = false;
}
  • Explicitly explained (to oneself) why AAcademyCharacter inherits from ACharacter rather than bare APawn — ground movement, crouching, and the capsule are needed from day one, not "might come in handy."
  • No field is added "for the future" — if a capability isn't needed right now, it's not in the class.
  • Documented which initialization must wait for BeginPlay (nothing in this step requires it yet, but the habit of stating this explicitly is established from the first file).
01
Step 01 — Health and inventory: components, not fields (Level 2)
Health is factored out not as a character field, but as a separate UHealthComponent — a decision justified not by "that's how it's done," but by the very combinatorial explosion analyzed in the article on components (imagine health being needed by both Character and the future AAcademyEnemy from step 08). For the same reason, inventory is UInventoryComponent over TArray<FAcademyInventorySlot>, not parallel arrays of "names + quantities" directly in the character class. The health component immediately gets FOnHealthChanged — a dynamic multicast delegate: it's not subscribed to anything yet (UI will only appear in step 04), but from this moment on, the component shouldn't know in advance who will be listening to it.
AAcademyCharacter.h — before (step 00)C++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();
};
AAcademyCharacter.h — afterC++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();

protected:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    TObjectPtr<UHealthComponent> HealthComponent;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    TObjectPtr<UInventoryComponent> InventoryComponent;
};
AAcademyCharacter.cpp — added to constructorC++
HealthComponent = CreateDefaultSubobject<UHealthComponent>(TEXT("HealthComponent"));
InventoryComponent = CreateDefaultSubobject<UInventoryComponent>(TEXT("InventoryComponent"));
HealthComponent.h — new fileC++
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, float, NewHealth);

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class ACADEMYGAME_API UHealthComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Health")
    float MaxHealth = 100.f;

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

    UPROPERTY(BlueprintAssignable)
    FOnHealthChanged OnHealthChanged;

    void ApplyDamage(float Amount);
protected:
    virtual void BeginPlay() override;
};
InventoryComponent.h — new fileC++
USTRUCT(BlueprintType)
struct FAcademyInventorySlot
{
    GENERATED_BODY()

    // The full UAcademyItemDataAsset type will only appear in step 03 — for now,
    // a forward declaration is enough; the component doesn't need to know what an item consists of.
    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TObjectPtr<UAcademyItemDataAsset> Item;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    int32 Count = 0;
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class ACADEMYGAME_API UInventoryComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Inventory")
    TArray<FAcademyInventorySlot> Slots;

    bool AddItem(UAcademyItemDataAsset* Item, int32 Count);
};
  • UHealthComponent and UInventoryComponent are reusable UActorComponents, not fields directly in the character.
  • FOnHealthChanged is declared inside the component and invoked by it when health changes — not polled from the outside every frame.
  • TArray<FAcademyInventorySlot> for inventory with a meaningful slot structure (item + quantity), not parallel arrays.
Fork
The designer asks to add a destructible chest with its own health and the same damage bar as the character. Should the chest inherit from AAcademyCharacter to reuse health?
Show the expected line of reasoning

No — a chest isn't a "character" in any meaningful sense (no movement, input, controller), inheriting from AAcademyCharacter for one shared field is precisely the mistake analyzed in the article on components. The correct solution is the same UHealthComponent attached to a separate, much simpler class AAcademyChest : public AActor. The component is designed precisely so this reuse does not require a common ancestor — step 08 will apply the same pattern to AAcademyEnemy.

02
Step 02 — Enhanced Input: control without hardcoded keys (Level 3 / 5.A)
The character can finally move — but not via "W"/"A"/"S"/"D" in code, but via named UInputActions connected through UInputMappingContext. A second context — InventoryMappingContext — is created immediately, so that future inventory opening (step 04) doesn't fight for the same keys as regular movement: it's added via AddMappingContext when opened and always removed via RemoveMappingContext when closed. The InteractAction handler is already declared as a field, but not yet connected — there's nothing to interact with yet; this is deliberately deferred until step 03, not "implemented just in case in advance."
AAcademyCharacter.h — after (added to step 01)C++
protected:
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputMappingContext> DefaultMappingContext;

    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputMappingContext> InventoryMappingContext;

    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> MoveAction;
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> LookAction;
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> InteractAction; // we'll connect this in step 03
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> ToggleInventoryAction;

    virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
    void Move(const FInputActionValue& Value);
    void Look(const FInputActionValue& Value);
    void ToggleInventory(const FInputActionValue& Value);
AAcademyCharacter.cppC++
void AAcademyCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    if (auto* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetController<APlayerController>()->GetLocalPlayer()))
    {
        Subsystem->AddMappingContext(DefaultMappingContext, 0);
    }

    if (auto* EIC = Cast<UEnhancedInputComponent>(PlayerInputComponent))
    {
        EIC->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AAcademyCharacter::Move);
        EIC->BindAction(LookAction, ETriggerEvent::Triggered, this, &AAcademyCharacter::Look);
        EIC->BindAction(ToggleInventoryAction, ETriggerEvent::Started, this, &AAcademyCharacter::ToggleInventory);
    }
}

void AAcademyCharacter::ToggleInventory(const FInputActionValue& Value)
{
    auto* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetController<APlayerController>()->GetLocalPlayer());
    if (!Subsystem) return;

    if (bInventoryOpen) { Subsystem->RemoveMappingContext(InventoryMappingContext); }
    else            { Subsystem->AddMappingContext(InventoryMappingContext, 1); } // priority higher than base
    bInventoryOpen = !bInventoryOpen;
}
  • At least two UInputMappingContexts: base (movement, look, future interact) and inventory.
  • Opening inventory adds the second context with a higher priority, closing — removes it (two consecutive opens-closes leave no duplicate context).
  • All gameplay actions are named UInputActions; none reads a physical key by name directly in C++.
03
Step 03 — Interaction and items as data (Level 4)
The world gets items that can be picked up. Instead of Cast<AAcademyItem> and tomorrow's similar Cast<AAcademyDoor> in the interaction code, UInteractableInterface (UINTERFACE) is introduced with an Interact method — a common capability without a common ancestor. The method itself is BlueprintNativeEvent, not BlueprintImplementableEvent: C++ retains the core logic (who interacts, what happens to the item), while the designer adds visual effects in Blueprint on top of it, without overriding the whole thing. An item's characteristics (name, icon, damage, stack) are not C++ class fields, but UAcademyItemDataAsset: the designer can balance the game without recompiling C++.
AAcademyCharacter.h — after (added to step 02)C++
protected:
    // FocusedInteractable — a strong reference: while the item is in focus, it must
    // be alive and visible to the GC just like any other active gameplay state.
    UPROPERTY()
    TObjectPtr<AActor> FocusedInteractable;

    void Interact(const FInputActionValue& Value);
AAcademyCharacter.cpp — InteractAction finally connectedC++
EIC->BindAction(InteractAction, ETriggerEvent::Started, this, &AAcademyCharacter::Interact);

void AAcademyCharacter::Interact(const FInputActionValue& Value)
{
    if (!FocusedInteractable || !FocusedInteractable->Implements<UInteractableInterface>()) return;
    IInteractableInterface::Execute_Interact(FocusedInteractable, this);
}
InteractableInterface.h — new fileC++
UINTERFACE(MinimalAPI, Blueprintable)
class UInteractableInterface : public UInterface { GENERATED_BODY() };

class IInteractableInterface
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintNativeEvent, Category="Interaction")
    void Interact(AActor* Instigator);
};
AcademyItemDataAsset.h — new fileC++
UCLASS(BlueprintType)
class ACADEMYGAME_API UAcademyItemDataAsset : public UDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly, Category="Item")
    FText DisplayName;

    // A heavy preview texture is only needed when the inventory is open (step 04) —
    // TSoftObjectPtr doesn't pull it into memory together with the DataAsset itself.
    UPROPERTY(EditDefaultsOnly, Category="Item")
    TSoftObjectPtr<UTexture2D> Icon;

    UPROPERTY(EditDefaultsOnly, Category="Item")
    int32 MaxStack = 1;

    // Used by the attack from step 07 — if the item is equipped as a weapon.
    UPROPERTY(EditDefaultsOnly, Category="Item")
    float AttackDamage = 0.f;
};
AcademyItem.h — new file, a pickable item in the levelC++
UCLASS()
class ACADEMYGAME_API AAcademyItem : public AActor, public IInteractableInterface
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, Category="Item")
    TObjectPtr<UAcademyItemDataAsset> ItemData;

    virtual void Interact_Implementation(AActor* Instigator) override;
};
  • UInteractableInterface implemented by AAcademyItem and (in the future) by any new interactive class without a common ancestor between them.
  • The Interact method is a BlueprintNativeEvent, with the reason explained why not BlueprintImplementableEvent.
  • Item characteristics are UAcademyItemDataAsset, editable without recompiling C++; the icon is TSoftObjectPtr, not a hard reference.
04
Step 04 — UI: health and inventory on screen (Level 5.B)
The health bar is built as a UMG widget with a BindWidget field for a UProgressBar, but it doesn't subscribe to UHealthComponent directly via Cast<AAcademyCharacter> in the widget — instead, the widget receives the value via UAcademyHealthViewModel (MVVM), which itself listens to the health component's OnHealthChanged from step 01. To allow the ViewModel to even reach the components, the character has to make a small but conscious step back: add public getters to fields that were protected since step 01 — this is the project's first example of a previous step's decision not being "wrong," but simply made for a smaller problem at the time.
AAcademyCharacter.h — before / afterC++
// Before (step 01): access only from within the class and its subclasses.
protected:
    TObjectPtr<UHealthComponent> HealthComponent;
    TObjectPtr<UInventoryComponent> InventoryComponent;

// After (step 04): ViewModel and UI code live outside the character class and
// have no right to Cast to its private state — they need an official entry point.
public:
    UHealthComponent* GetHealthComponent() const { return HealthComponent; }
    UInventoryComponent* GetInventoryComponent() const { return InventoryComponent; }
AcademyHealthViewModel.h — new fileC++
UCLASS()
class ACADEMYGAME_API UAcademyHealthViewModel : public UMVVMViewModelBase
{
    GENERATED_BODY()
public:
    void InitFor(UHealthComponent* InHealthComponent);

    UPROPERTY(FieldNotify, BlueprintReadOnly)
    float HealthFraction = 1.f;

private:
    void HandleHealthChanged(float NewHealth);
};
  • The health bar is a UMG widget with BindWidget, receiving data through a ViewModel, not a direct Cast to AAcademyCharacter.
  • The ViewModel subscribes to FOnHealthChanged of the health component from step 01, rather than polling it every frame.
  • The inventory screen reads InventoryComponent->Slots via the same getter principle as health — not by directly digging into the character's fields.
Note — step 07 will return here

In step 07, health moves from UHealthComponent to UAcademyAttributeSet (GAS). UAcademyHealthViewModel, written here, will need to be rebind to the new data source — this won't be hidden: see the "Before / After" block in step 07.

05
Step 05 — Gameplay Framework: retroactive formalization of roles (Level 3)
Up to this step, AAcademyCharacter silently combined "body" and "brain": it itself read Enhanced Input in step 02. This wasn't a mistake at the time — Level 3 (Gameplay Framework) hadn't been covered yet — but now that the roles are known, this is explicitly documented as a refactoring, not as something "always planned that way." Input and camera move to AAcademyPlayerController: it subscribes to Enhanced Input and calls the character's existing methods, rather than reading its internal fields. Data that must survive death and respawn (the count of collected items) moves to AAcademyPlayerState. Win conditions and spawn points — on the server, in AAcademyGameMode.
AAcademyCharacter.h — before (steps 02–04)C++
protected:
    TObjectPtr<UInputMappingContext> DefaultMappingContext;
    TObjectPtr<UInputMappingContext> InventoryMappingContext;
    TObjectPtr<UInputAction> MoveAction, LookAction, InteractAction, ToggleInventoryAction;

    virtual void SetupPlayerInputComponent(UInputComponent*) override;
    void Move(const FInputActionValue&);
    void Look(const FInputActionValue&);
    void Interact(const FInputActionValue&);
    void ToggleInventory(const FInputActionValue&);
AAcademyCharacter.h — after: the entire Input block removed, the character is now only an executorC++
public:
    // Called by the controller, not bound to a key directly inside the character.
    void RequestMove(const FVector2D& Axis);
    void RequestLook(const FVector2D& Axis);
    void RequestInteract();
    void RequestToggleInventory();
AcademyPlayerController.h — new file, takes the Input block from the characterC++
UCLASS()
class ACADEMYGAME_API AAcademyPlayerController : public APlayerController
{
    GENERATED_BODY()
protected:
    virtual void SetupInputComponent() override; // the same AddMappingContext/BindAction logic as in step 02

    void Move(const FInputActionValue& Value)
    {
        if (auto* Character = GetPawn<AAcademyCharacter>())
            Character->RequestMove(Value.Get<FVector2D>());
    }
};
AcademyPlayerState.h — new fileC++
UCLASS()
class ACADEMYGAME_API AAcademyPlayerState : public APlayerState
{
    GENERATED_BODY()
public:
    // Survives death and respawn of the character — AAcademyCharacter itself does not survive.
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Progress")
    int32 ItemsEverCollected = 0;
};
  • Input and camera control are in AAcademyPlayerController; the character no longer reads Input directly in itself.
  • Data that survives Pawn recreation (the count of collected items) is in AAcademyPlayerState, not in AAcademyCharacter itself.
  • AAcademyGameMode exists only on the server and stores win/spawn rules; AAcademyGameState replicates what all clients should see.
Fork
The character dies and respawns as a new Pawn after 5 seconds. Where should the score accumulated during the game be stored — in AAcademyCharacter, AAcademyPlayerState, or AAcademyGameState?
Show the expected line of reasoning

In AAcademyPlayerState. AAcademyCharacter is destroyed on death along with all its fields — storing data that survives death there means losing it on every respawn. AAcademyGameState is also the wrong level: this is a specific player's data, not the match's common state. The rule is simple: if data must survive the character's death but belongs to one specific player — it's PlayerState, not Character and not GameState.

06
Step 06 — Multiplayer: the character is no longer local (Level 6)
The first truly painful refactoring of the project: health from UHealthComponent becomes Replicated, interaction from step 03 no longer directly changes state on the client and turns into a Server RPC with distance and cooldown re-checked on the server (otherwise any client could pick up an item across the level with one packet). Here, the first version of attack also appears — a direct Server RPC subtracting health via ApplyDamage. This is deliberately a naive implementation: in step 07 it will become a UGameplayAbility, and it's explicitly noted here that it's temporary.
AAcademyCharacter.h — after (added to step 05)C++
public:
    void RequestInteract();
    void RequestAttack(); // temporary — until GAS in step 07

protected:
    UFUNCTION(Server, Reliable)
    void ServerInteract(AActor* Target);

    UFUNCTION(Server, Reliable)
    void ServerAttack();
AAcademyCharacter.cppC++
void AAcademyCharacter::ServerInteract_Implementation(AActor* Target)
{
    // The server doesn't trust the client — distance and cooldown are re-checked,
    // even if the client already checked them locally for instant feedback.
    if (!Target || GetDistanceTo(Target) > InteractRange) return;
    IInteractableInterface::Execute_Interact(Target, this);
}

void AAcademyCharacter::ServerAttack_Implementation()
{
    if (AActor* Target = FindAttackTarget())
        Target->FindComponentByClass<UHealthComponent>()->ApplyDamage(10.f);
}
HealthComponent.h — after: Replicated + roleC++
UPROPERTY(ReplicatedUsing=OnRep_CurrentHealth, VisibleAnywhere, BlueprintReadOnly, Category="Health")
float CurrentHealth;

UFUNCTION()
void OnRep_CurrentHealth(); // updates damage visuals on clients and calls OnHealthChanged
  • HealthComponent::CurrentHealth is UPROPERTY(Replicated), with an OnRep function updating damage visuals on clients.
  • Interact from step 03 is reworked: the client calls a Server RPC, the server re-checks distance/cooldown independently of what the client sent.
  • The character code has at least one place where GetLocalRole()/GetRemoteRole() is explicitly checked before executing logic.
Fork
After this step, Interact is called via a Server RPC. Should the client-side distance check now be removed, since the server re-checks it anyway?
Show the expected line of reasoning

No — the client-side check remains as an early, local cutoff, providing instant feedback to the player (no need to wait for the round-trip to the server to know if "too far"), while the server-side check remains the single source of truth that cannot be trusted to the client. The server must re-check every Server RPC, not rely on the client having already checked.

07
Step 07 — GAS: abilities on top of an already replicated character (Level 5.C)
The largest refactoring of the project: health from UHealthComponent (step 01, replicated in step 06) is moved to UAcademyAttributeSet as FGameplayAttributeData with separate Base/CurrentValue — the old storage as a simple Replicated float cannot correctly roll back a single temporary buff if multiple are applied. The naive ServerAttack from step 06 becomes a UGameplayAbility with explicit ActivateAbility/EndAbility; damage is applied via UGameplayEffect (Instant), not by direct subtraction. The tag State.Attacking via ActivationBlockedTags prevents the ability from restarting on top of itself — replacing a manual if-check. The moment of impact stores the damage source in TWeakObjectPtr<AActor> — the character should neither own the attacker nor keep it from garbage collection just because a pointer to it sits in a field somewhere.
AAcademyCharacter.h — before (step 06)C++
protected:
    TObjectPtr<UHealthComponent> HealthComponent;
    UFUNCTION(Server, Reliable)
    void ServerAttack();
AAcademyCharacter.h — afterC++
public:
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override { return AbilitySystemComponent; }

protected:
    // HealthComponent from steps 01/06 is replaced — the old Health field is not removed silently:
    // the reason is documented above in the step's text; the component couldn't roll back buffs.
    UPROPERTY()
    TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;

    UPROPERTY()
    TObjectPtr<UAcademyAttributeSet> AttributeSet;

    // Who last dealt damage — for the HUD "killed by player X". Weak reference:
    // the character doesn't own the attacker and shouldn't prevent its garbage collection.
    TWeakObjectPtr<AActor> LastDamageInstigator;
AcademyAttributeSet.h — new file, replaces HealthComponentC++
UCLASS()
class ACADEMYGAME_API UAcademyAttributeSet : public UAttributeSet
{
    GENERATED_BODY()
public:
    UPROPERTY(BlueprintReadOnly, Category="Attributes")
    FGameplayAttributeData Health;
    UPROPERTY(BlueprintReadOnly, Category="Attributes")
    FGameplayAttributeData MaxHealth;

    virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
};
AcademyAttackAbility.h — new file, replaces ServerAttackC++
UCLASS()
class ACADEMYGAME_API UAcademyAttackAbility : public UGameplayAbility
{
    GENERATED_BODY()
public:
    UAcademyAttackAbility()
    {
        // A tag instead of a manual "if (bIsAttacking) return" — GAS itself won't
        // allow the ability to activate again until the tag is removed in EndAbility.
        ActivationBlockedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("State.Attacking")));
    }

    virtual void ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo,
        const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData) override;
};
  • UAcademyAttributeSet with Health/MaxHealth as FGameplayAttributeData — the old HealthComponent field is explicitly marked as replaced, not just deleted without a trace.
  • Attack is a UGameplayAbility activated via the ASC, not the old Server RPC directly.
  • Damage application is a UGameplayEffect (Instant) with a Modifier on Health, applied via ApplyGameplayEffectToTarget.
  • At least one ability uses ActivationBlockedTags instead of a manual if-check.
Return to step 04 — the ViewModel is rebound

UAcademyHealthViewModel::InitFor from step 04 accepted UHealthComponent*. Now it accepts UAbilitySystemComponent* and subscribes to GetGameplayAttributeValueChangeDelegate(UAcademyAttributeSet::GetHealthAttribute()) instead of OnHealthChanged. This is not rewriting history: rule 2 requires such a move to be an explicit refactoring point of this step, not a silent edit back in step 04 retroactively.

08
Step 08 — AI enemy on the same systems as the player (Level 5.D)
The first NPC enemy, AAcademyEnemy, reuses UAcademyAttributeSet and UGameplayEffect designed for the player in step 07 — the moment when it becomes visible whether the reusability decisions paid off. Enemy behavior is built as a Behavior Tree (patrol → notice player → chase → attack → retreat at low HP); the "current target" state lives in Blackboard, not in a controller field, and position selection for an attack is via an EQS query, not manual point enumeration in C++.
AcademyEnemy.h — new fileC++
UCLASS()
class ACADEMYGAME_API AAcademyEnemy : public ACharacter, public IAbilitySystemInterface
{
    GENERATED_BODY()
public:
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override { return AbilitySystemComponent; }

protected:
    // The same classes as in AAcademyCharacter from step 07 — neither health
    // nor damage are duplicated in a separate implementation for the enemy.
    UPROPERTY()
    TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;
    UPROPERTY()
    TObjectPtr<UAcademyAttributeSet> AttributeSet;
};
Behavior Tree — the script structureDiagram
Root
 └─ Selector
     ├─ Sequence "Attack" (condition: target in attack range)
     │   └─ Task: AcademyAttack (activates UAcademyAttackAbility via ASC)
     ├─ Sequence "Chase" (condition: target visible, HP > 20%)
     │   ├─ Task: EQS Query "BestChasePoint"
     │   └─ Task: MoveTo (Blackboard key TargetActor)
     ├─ Sequence "Retreat" (condition: HP <= 20%)
     │   └─ Task: MoveTo (retreat point)
     └─ Task: Patrol (Blackboard key PatrolPoint)
  • AAcademyEnemy uses the same UAcademyAttributeSet as the player, without duplicating its logic.
  • Behavior Tree with at least five nodes from the "patrol → notice → chase → attack → retreat" scenario.
  • A Blackboard key "current target" instead of an AIController field; at least one EQS query for choosing an attack point.
09
Step 09 — Save/Load: saving the progress of the same character (Level 8)
Not an abstract example is saved, but the real state of an already existing project: health from UAcademyAttributeSet (step 07), the contents of InventoryComponent (step 01), and the score from AAcademyPlayerState (step 05). UAcademySaveGame stores the save version from day one — not because it's needed now, but because "just adding a field" in a future update is the most common way to break other people's saves if the version wasn't put in place in advance.
AcademySaveGame.h — new fileC++
UCLASS()
class ACADEMYGAME_API UAcademySaveGame : public USaveGame
{
    GENERATED_BODY()
public:
    UPROPERTY()
    int32 SaveVersion = 1;

    UPROPERTY()
    float SavedHealth = 100.f;

    UPROPERTY()
    TArray<FAcademyInventorySlot> SavedInventory;

    UPROPERTY()
    int32 SavedItemsEverCollected = 0;
};
AAcademyCharacter.h / .cpp — after: saving reads from already existing subsystemsC++
public:
    void SaveProgress();
    void LoadProgress();

// .cpp
void AAcademyCharacter::SaveProgress()
{
    auto* Save = Cast<UAcademySaveGame>(UGameplayStatics::CreateSaveGameObject(UAcademySaveGame::StaticClass()));
    Save->SavedHealth = AbilitySystemComponent->GetNumericAttribute(UAcademyAttributeSet::GetHealthAttribute());
    Save->SavedInventory = InventoryComponent->Slots;
    Save->SavedItemsEverCollected = GetPlayerState<AAcademyPlayerState>()->ItemsEverCollected;
    UGameplayStatics::SaveGameToSlot(Save, TEXT("Slot0"), 0);
}
  • UAcademySaveGame with SaveVersion from day one, not added retroactively after the first update that broke something.
  • Saves the project's real subsystems — AttributeSet, InventoryComponent, PlayerState — not an abstract example of "save a number."
  • Loading restores health through GAS (attribute modifier), not by direct write to AttributeSet's private field.
Fork
In the next update, UAcademySaveGame needs to add a field EquippedWeaponId. What happens to old saves if the field is simply added without changing SaveVersion and the load logic?
Show the expected line of reasoning

Formally, deserialization won't crash — the new field in old saves will simply take its default value. The problem is not a crash, but a silent loss of meaning: the game will decide the weapon is not equipped, even though the player had chosen it before the update. This is exactly why SaveVersion is needed from day one — by it, the load code can explicitly ask "is this save older than the moment when weapons appeared?" and substitute a conscious default, rather than relying on the random behavior of serialization.

10
Step 10 — Optimization and profiling of an already existing project (Level 8)
This step is about already written code, not an abstract example of "imagine a project with performance problems." AAcademyCharacter::Tick has quietly accumulated work that should never have been there — regenerating stamina every frame, even though the player doesn't care whether it happened on this frame or five frames later. stat unit/stat game and a trace in Unreal Insights show this specifically, not hypothetically: the character's Tick, BT ticks from step 08, and replication from step 06 are three real candidates for this same project's frame budget, not invented ones.
AAcademyCharacter.cpp — beforeC++
void AAcademyCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    // Stamina regenerates every frame — at 120 FPS, this is 120 useless
    // calls per second for a value that the UI updates no more than 10 times a second anyway.
    Stamina = FMath::Min(MaxStamina, Stamina + StaminaRegenRate * DeltaTime);
}
AAcademyCharacter.cpp — afterC++
// Tick() is no longer overridden for this logic — regeneration moved to a timer
// with a frequency actually needed by gameplay, not the frame rate.
void AAcademyCharacter::BeginPlay()
{
    Super::BeginPlay();
    GetWorldTimerManager().SetTimer(StaminaTimer, this, &AAcademyCharacter::RegenStamina, 0.1f, true);
}
  • At least one real Tick of the project (not a hypothetical example) is replaced by a timer with a justified frequency.
  • A session profile has been captured with this same character, the enemy from step 08, and replication from step 06 in Unreal Insights — not an abstract scene.
  • Specific stat commands used to check for regressions after this optimization are named (to oneself, in writing).
11
Step 11 — Final build
By this point, AAcademyCharacter has gone from a bare inheritor of ACharacter (step 00) to a networked character with component-based health and inventory (step 01), controlled without a single hardcoded key (step 02), interacting with data-defined items (step 03), displayed on screen via MVVM (step 04), with roles distributed across the entire Gameplay Framework (step 05), replicated (step 06), with combat abilities on GAS (step 07), with an enemy honestly reusing the same combat system (step 08), saving progress (step 09), and profiled as a real project rather than a hypothetical example (step 10). None of these ten steps silently rewrote the previous one — each refactoring above clearly shows "Before / After" and the reason.
💡 Senior Thoughts

An isolated exercise checks whether you understood an idea. A capstone project checks something else — whether your decision today can withstand the pressure of all the decisions you will make later. This pressure, not any single article, is the real source of architectural experience — in a real project, course topics never arrive one at a time.

What is intentionally not in the project

Level Streaming/World Partition (Level 5.E) and Animation (Level 5.F) are full-fledged course sections with their own articles, but they have been consciously left out of any of the 12 steps above: the test room where AAcademyCharacter lives never grows large enough for streaming sublevels to become a real necessity, rather than an artificially attached example — the same goes for character animation on top of an already complete set of capabilities. Both topics are consciously left as parallel tracks of Level 5, not woven into the single narrative line of the survival character, to avoid showing code "because the topic exists" rather than because this particular project needs it. The architecture of a codebase of a scale that this project naturally outgrows at some point is the topic of the course's closing article.

```