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.
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.
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.UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AAcademyCharacter();
};
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
AAcademyCharacterinherits fromACharacterrather than bareAPawn— 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).
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.UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
GENERATED_BODY()
public:
AAcademyCharacter();
};
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;
};
HealthComponent = CreateDefaultSubobject<UHealthComponent>(TEXT("HealthComponent"));
InventoryComponent = CreateDefaultSubobject<UInventoryComponent>(TEXT("InventoryComponent"));
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;
};
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);
};
UHealthComponentandUInventoryComponentare reusableUActorComponents, not fields directly in the character.FOnHealthChangedis 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.
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.
"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."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);
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++.
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++.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);
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);
}
UINTERFACE(MinimalAPI, Blueprintable)
class UInteractableInterface : public UInterface { GENERATED_BODY() };
class IInteractableInterface
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintNativeEvent, Category="Interaction")
void Interact(AActor* Instigator);
};
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;
};
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;
};
UInteractableInterfaceimplemented byAAcademyItemand (in the future) by any new interactive class without a common ancestor between them.- The
Interactmethod is aBlueprintNativeEvent, with the reason explained why notBlueprintImplementableEvent. - Item characteristics are
UAcademyItemDataAsset, editable without recompiling C++; the icon isTSoftObjectPtr, not a hard reference.
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.// 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; }
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 directCasttoAAcademyCharacter. - The ViewModel subscribes to
FOnHealthChangedof the health component from step 01, rather than polling it every frame. - The inventory screen reads
InventoryComponent->Slotsvia the same getter principle as health — not by directly digging into the character's fields.
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.
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.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&);
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();
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>());
}
};
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 inAAcademyCharacteritself. AAcademyGameModeexists only on the server and stores win/spawn rules;AAcademyGameStatereplicates what all clients should see.
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.
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.public:
void RequestInteract();
void RequestAttack(); // temporary — until GAS in step 07
protected:
UFUNCTION(Server, Reliable)
void ServerInteract(AActor* Target);
UFUNCTION(Server, Reliable)
void ServerAttack();
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);
}
UPROPERTY(ReplicatedUsing=OnRep_CurrentHealth, VisibleAnywhere, BlueprintReadOnly, Category="Health")
float CurrentHealth;
UFUNCTION()
void OnRep_CurrentHealth(); // updates damage visuals on clients and calls OnHealthChanged
HealthComponent::CurrentHealthisUPROPERTY(Replicated), with anOnRepfunction updating damage visuals on clients.Interactfrom 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.
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.
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.protected:
TObjectPtr<UHealthComponent> HealthComponent;
UFUNCTION(Server, Reliable)
void ServerAttack();
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;
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;
};
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;
};
UAcademyAttributeSetwith Health/MaxHealth asFGameplayAttributeData— the oldHealthComponentfield is explicitly marked as replaced, not just deleted without a trace.- Attack is a
UGameplayAbilityactivated via the ASC, not the old Server RPC directly. - Damage application is a
UGameplayEffect(Instant) with a Modifier on Health, applied viaApplyGameplayEffectToTarget. - At least one ability uses
ActivationBlockedTagsinstead of a manual if-check.
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.
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++.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;
};
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)
AAcademyEnemyuses the sameUAcademyAttributeSetas 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.
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.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;
};
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);
}
UAcademySaveGamewithSaveVersionfrom 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.
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.
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.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);
}
// 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
Tickof 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
statcommands used to check for regressions after this optimization are named (to oneself, in writing).
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.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.
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.