Terug naar Blog

Waarom het aanpassen van Blueprint Defaults tijdens runtime faalt: Het ontwerpen van een veilige Unreal Engine upgrade shop

Gepubliceerd op 13 juli 2026
Waarom het aanpassen van Blueprint Defaults tijdens runtime faalt: Het ontwerpen van een veilige Unreal Engine upgrade shop

Kort samengevat

Deze tutorial legt uit waarom het direct wijzigen van Blueprint-variabelen via Class Default Objects (CDO) tijdens runtime faalt in Unreal Engine. De auteur toont hoe je een persistent wapensysteem ontwerpt door stats te scheiden van de visuele actor en deze op te slaan in de `PlayerState`. Tot slot wordt besproken hoe je deze upgrades beveiligt tegen client-side exploits en permanent opslaat met een cloud-backend zoals horizOn.

Je bent weken bezig met het bouwen van een modulair wapensysteem in Unreal Engine, instantieert een child blueprint voor je geweer, en zet een strakke UMG shop-menu op om de herlaadsnelheid te upgraden, om er vervolgens achter te komen dat de upgrades verdwijnen zodra de speler van wapen wisselt of het level herstart. Nog erger: wanneer je probeert te casten naar de Blueprint class in je widget-menu en de variabelen aanpast, gebeurt er niets. Dit is een beruchte bottleneck voor developers die progressiesystemen bouwen.

In deze tutorial ontrafelen we waarom Blueprint-aanpassingen tijdens runtime falen, hoe je een persistent wapensysteem ontwerpt en hoe je die upgrades veilig opslaat in een database.

Het kernprobleem: Waarom het aanpassen van Blueprint Class Defaults tijdens runtime faalt

Wanneer je variabelen bewerkt in een Blueprint binnen de Unreal Editor, pas je het Class Default Object (CDO) aan. Het CDO fungeert als de master template voor elke instance van die class die in de gamewereld wordt gespawned. Tijdens runtime is het direct aanpassen van het CDO echter streng beperkt, en met een goede reden. Als je een CDO-variabele wijzigt, loop je het risico de standaardwaarde voor alle toekomstige spawns aan te passen, wat serialization-problemen veroorzaakt en replication breekt.

De meest gemaakte fout is het proberen te casten van een class reference (TSubclassOf<AActor>) naar een instance van de class. Als je widget-menu een variabele bevat van het type "Weapon Class" (bijv. BP_Rifle_Child) en je probeert de variabelen direct in te stellen, dan richt je je op de class template en niet op de actieve actor in de handen van de speler. Zelfs als het lukt om te casten naar de actieve actor-instance (bijv. het geweer dat momenteel is uitgerust) en de BaseDamage wijzigt van 25 naar 50, is die verandering tijdelijk.

Op het moment dat de speler het geweer wegsteekt, naar een pistool wisselt en het geweer opnieuw uitrust, vernietigt de game de oude geweer-actor en spawnt een nieuwe. De nieuwe actor wordt vers gespawned vanaf de CDO-template, waardoor je geüpgradede BaseDamage weer wordt gereset naar de standaardwaarde van 25. Om upgrades persistent te maken, moet je de statistieken van het wapen loskoppelen (decouple) van de visuele actor die in de wereld wordt gespawned.

De architectuur van een persistent wapensysteem

Om dit op te lossen, moeten we State (de statistieken van het wapen) scheiden van Presentation (de actor die het wapen rendert en de projectile spawns afhandelt). In plaats van de authoritatieve damage, herlaadsnelheid en ammo-capaciteit in de wapen-actor zelf op te slaan, bewaren we ze in een persistente datastructuur. Deze structuur moet in een class leven die blijft bestaan na het vernietigen van de actor, zoals de APlayerState, AGameState of een custom inventory-component.

Voor multiplayer-games vereist het opslaan van deze variabelen in een custom component zorgvuldig beheer van ownership. Als je wapen-states opslaat in een custom ActorComponent, zorg er dan voor dat je niet tegen multiplayer-inventory-nachtmerries aanloopt die worden veroorzaakt door verkeerde actor component ownership tijdens replication. Door de authoritatieve data onder te brengen in de APlayerState, zorg je ervoor dat de data bewaard blijft, zelfs wanneer de speler sterft, van level wisselt of van wapen verandert.

Wanneer de speler de upgrade shop opent, communiceert de UI-widget direct met de datastate van de speler. Het kopen van een upgrade wijzigt de persistente struct, niet de wapen-actor. Wanneer de speler een wapen uitrust, spawnt de character class de wapen-actor en initialiseert deze direct met de datastruct uit de player state. Dit garandeert dat elk nieuw gespawned wapen de juiste, geüpgradede stats overneemt.

Stapsgewijze implementatie: Data en actor-logica loskoppelen

Laten we een schone C++-implementatie schrijven van deze ontkoppelde architectuur. We definiëren een FWeaponStats struct die de upgradebare waarden bevat, en een basis-wapenclass die dynamisch kan worden geconfigureerd.

1. De Weapon Stats Struct definiëren

Eerst definiëren we onze datastructuur. Deze structuur is toegankelijk voor Blueprints, zodat je UI-widgets en child blueprints voor designers gemakkelijk stats kunnen lezen en schrijven.

#pragma once

#include "CoreMinimal.h"
#include "WeaponStats.generated.h"

USTRUCT(BlueprintType)
struct FWeaponStats
{
    GENERATED_BODY()

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    float BaseDamage = 25.0f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    float ReloadSpeedModifier = 1.0f;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    int32 MaxAmmo = 30;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Stats")
    int32 CurrentUpgradeLevel = 0;
};

Door wapenstats in te kapselen in een enkele FWeaponStats struct, maken we het triviaal om deze te serialiseren, te repliceren en door te geven. In plaats van het beheren van vijf afzonderlijke gerepliceerde float-variabelen, repliceren we één enkele struct, wat de replication-overhead vermindert.

2. De basis-wapenclass aanmaken

Vervolgens maken we de basis-wapenclass AWeaponBase aan. Deze class vertegenwoordigt de fysieke actor in de wereld en bevat een functie om zichzelf te initialiseren met nieuwe stats.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "WeaponStats.h"
#include "WeaponBase.generated.h"

UCLASS()
class SHOOTER_API AWeaponBase : public AActor
{
    GENERATED_BODY()
    
public:    
    AWeaponBase();

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Weapon")
    USkeletalMeshComponent* WeaponMesh;

    UPROPERTY(Replicated, BlueprintReadOnly, Category = "Weapon")
    FWeaponStats WeaponStats;

    UFUNCTION(BlueprintCallable, Category = "Weapon")
    void InitializeWeapon(const FWeaponStats& NewStats);

    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
#include "WeaponBase.h"
#include "Net/UnrealNetwork.h"

AWeaponBase::AWeaponBase()
{
    PrimaryActorTick.bCanEverTick = false;
    bReplicates = true;

    WeaponMesh = CreateDefaultSubobject<USkeletalMeshComponent>(TEXT("WeaponMesh"));
    RootComponent = WeaponMesh;
}

void AWeaponBase::InitializeWeapon(const FWeaponStats& NewStats)
{
    WeaponStats = NewStats;
    // Apply changes dynamically to the active actor
    // e.g., Adjust weapon mesh scale, update firing rate variables, or UI indicators
}

void AWeaponBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(AWeaponBase, WeaponStats);
}

In de initialisatiefunctie wijzen we de doorgegeven stats toe aan onze gerepliceerde WeaponStats-variabele. In een praktijkscenario zou je hier ook visuele of functionele aanpassingen triggeren — zoals het aanpassen van de grootte van een magazine mesh, het wijzigen van fire rate timers of het aanpassen van de schaal van particle systems op basis van de nieuwe stats. In plaats van elke keer dat een stat verandert een Blueprint-asset van 20MB te herladen, bespaart het doorgeven van een 48-byte C++ struct enorme memory-overhead.

3. De Upgrade Shop Widget integreren met de Player State

Om upgrades authoritatief te beheren, moet het UI-shopmenu communiceren met de PlayerState in plaats van direct te proberen te casten naar tijdelijke actor-instances. Laten we de AShooterPlayerState class ontwerpen om de spelerstats te beheren en de upgrades te valideren.

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerState.h"
#include "WeaponStats.h"
#include "ShooterPlayerState.generated.h"

UCLASS()
class SHOOTER_API AShooterPlayerState : public APlayerState
{
    GENERATED_BODY()

public:
    AShooterPlayerState();

    UPROPERTY(ReplicatedUsing = OnRep_WeaponInventory, BlueprintReadOnly, Category = "Inventory")
    TArray<FWeaponStats> WeaponInventory;

    UFUNCTION(BlueprintCallable, Category = "Inventory")
    FWeaponStats GetWeaponStats(int32 WeaponIndex) const;

    UFUNCTION(Server, Reliable, WithValidation, BlueprintCallable, Category = "Inventory")
    void Server_UpgradeWeapon(int32 WeaponIndex);

    UPROPERTY(Replicated, BlueprintReadOnly, Category = "Economy")
    int32 PlayerGold = 500;

    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;

protected:
    UFUNCTION()
    void OnRep_WeaponInventory();
};
#include "ShooterPlayerState.h"
#include "Net/UnrealNetwork.h"

AShooterPlayerState::AShooterPlayerState()
{
    bReplicates = true;
}

FWeaponStats AShooterPlayerState::GetWeaponStats(int32 WeaponIndex) const
{
    if (WeaponInventory.IsValidIndex(WeaponIndex))
    {
        return WeaponInventory[WeaponIndex];
    }
    return FWeaponStats();
}

bool AShooterPlayerState::Server_UpgradeWeapon_Validate(int32 WeaponIndex)
{
    if (!WeaponInventory.IsValidIndex(WeaponIndex)) return false;

    int32 UpgradeCost = (WeaponInventory[WeaponIndex].CurrentUpgradeLevel + 1) * 100;
    return PlayerGold >= UpgradeCost;
}

void AShooterPlayerState::Server_UpgradeWeapon_Implementation(int32 WeaponIndex)
{
    int32 UpgradeCost = (WeaponInventory[WeaponIndex].CurrentUpgradeLevel + 1) * 100;
    PlayerGold -= UpgradeCost;

    FWeaponStats& Stats = WeaponInventory[WeaponIndex];
    Stats.CurrentUpgradeLevel++;
    Stats.BaseDamage += 10.0f;
    Stats.ReloadSpeedModifier *= 0.9f;
}

void AShooterPlayerState::OnRep_WeaponInventory()
{
    // Update local UI representation or bind to UI delegates
}

void AShooterPlayerState::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(AShooterPlayerState, WeaponInventory);
    DOREPLIFETIME(AShooterPlayerState, PlayerGold);
}

De AShooterPlayerState class beheert de currency en de inventory van de speler met daarin de wapenstats. Door gebruik te maken van RPC's (Server_UpgradeWeapon) en replication, blijft de server de single source of truth voor de stats van de speler.

Let op de Server_UpgradeWeapon_Validate-functie. Het RPC-validatiesysteem van Unreal kickt automatisch clients die ongeldige verzoeken sturen, zoals het proberen te upgraden van een wapen zonder voldoende goud.

Zodra de server de upgrade verwerkt, trekt deze het goud af, updatet de stats en repliceert de wijzigingen terug naar de client. Deze replication triggert automatisch client-side UI-updates via replication callbacks.

Client-side exploits in je upgrade shop voorkomen

Als je game een multiplayer is, of als je je singleplayer-economie wilt beschermen tegen memory editors, kun je de client niet vertrouwen om zijn eigen upgrades te beheren. Als de client-side UI-widget Server_UpgradeWeapon(int32 NewDamage) mag aanroepen, kan een hacker eenvoudig netwerkpakketten onderscheppen of tools zoals Cheat Engine gebruiken om een pakket te verzenden waarin staat dat de schade van hun wapen 999.999 is.

Zonder strikte server-authoritative validatie zal je game te maken krijgen met game-breaking multiplayer state-desyncs waarbij de lokale UI van de client denkt dat ze een geüpgraded wapen hebben, maar de server de schade berekent op basis van de oorspronkelijke level 1 stats. Om dit te voorkomen, mag de client alleen de intentie sturen om te upgraden, zoals Server_RequestUpgrade(FName WeaponID). De server voert de transactie vervolgens authoritatief uit.

De servervalidatie-flow moet deze stappen volgen:

  1. Verify Resources: Controleer of de speler daadwerkelijk genoeg goud of scrap heeft om de upgrade te kopen.
  2. Validate Upgrade Path: Bevestig dat de aangevraagde upgrade de volgende in de reeks is (bijv. Level 2 naar Level 3, en niet direct springen naar Level 10).
  3. Deduct Cost and Apply: Trek de valuta af op de server en update de persistente stats-struct van de speler.
  4. Replicate and Sync: Repliceer de geüpdatete struct terug naar de client, die automatisch het uitgeruste wapen bijwerkt.

Upgrades opslaan in de backend-database

Hoewel het bewaren van stats in de PlayerState prima werkt tijdens een enkele match, worden die stats gewist op het moment dat de speler de game afsluit of de server herstart. Om een echte progressieloop te creëren, moet je deze dynamische variabele-wijzigingen opslaan in een persistente backend-database.

Dit handmatig zelf bouwen is een enorme onderneming. Je moet een SQL- of NoSQL-database inrichten, een API-gateway opzetten met OAuth2-authenticatie, aangepaste serverlogica implementeren om JSON-payloads te parsen, en omgaan met edge cases zoals verbindingstime-outs en database sharding. Het opzetten van deze infrastructuur kost al snel 4 tot 6 weken aan toegewijde ontwikkeltijd, wat de focus weghaalt bij het polijsten van je core gameplay loop.

Dit is waar horizOn van pas komt, waarmee je de progressiedata van spelers veilig in de cloud kunt opslaan zonder backend-databasecode te schrijven. Je kunt de backend SDK gebruiken om je FWeaponStats struct te serialiseren naar JSON en deze rechtstreeks op te slaan in het cloudprofiel van de speler met server-authoritative security rules. Een typische JSON-payload voor wapenstats is kleiner dan 500 bytes (ongeveer 320 bytes voor een standaard loadout), wat betekent dat database-operaties binnen 15 ms worden uitgevoerd. Deze snelheid houdt UI-transities vlot en zorgt ervoor dat spelers geen stotteringen (stuttering) ervaren bij het openen van shop-menu's of het afronden van transacties.

Je kunt bijvoorbeeld een beveiligde Cloud Code-functie in de backend schrijven die de aankoop van de upgrade valideert. Wanneer de speler op "Buy Upgrade" klikt in je widget-menu, stuurt de game een beveiligd API-verzoek. De database verifieert de inventory van de speler, schrijft de currency af, slaat het nieuwe wapenlevel op en retourneert de geüpdatete stat-payload. Dit zorgt ervoor dat zelfs als een speler zijn lokale geheugen hackt, de authoritatieve cloud-database veilig blijft.

Best practices voor Unreal Engine upgrade-systemen

Volg deze kernprincipes om ervoor te zorgen dat je upgrade shop zowel stabiel als performant is:

  1. Wijzig nooit Class Default Objects (CDO's) tijdens runtime: Behandel CDO's als read-only Blueprints. Gebruik ze uitsluitend voor het spawnen van de standaard visuele meshes en initiële templates.
  2. Ontkoppel stats van actors: Sla authoritatieve stats op in een persistente class zoals APlayerState of GameInstance, en geef ze door aan de actor bij het spawnen.
  3. Dwing server-authoriteit af: Laat clients nooit direct statistiekwijzigingen dicteren. Clients vragen upgrades aan; de server valideert de resources en past de wijziging toe.
  4. Gebruik structs voor serialization: Groepeer upgradebare stats in USTRUCTs. Dit maakt serialization voor lokale saves of backend-database-aanroepen naadloos.
  5. Optimaliseer database-payloads: Houd spelerprofielen slank. Sla alleen de upgrade-levels op (bijv. WeaponLevel: 3) in de database, en reconstrueer de daadwerkelijke floats (bijv. Damage: 45.0f) op de gameserver.

Samenvatting en volgende stappen

Het oplossen van dynamische variabele-updates op child blueprints vereist een verschuiving van een actor-centric naar een data-centric ontwerp. Door stats op te slaan in persistente structs op de PlayerState en je wapens dynamisch te initialiseren, voorkom je dat upgrades verdwijnen wanneer er van wapen wordt gewisseld. Wanneer je klaar bent om dit progressiesysteem multiplayer te maken en te beveiligen in de cloud, is de ondersteuning van een backend-database essentieel.

Klaar om je progressiesystemen op te schalen en de inventories van spelers te beveiligen? Probeer horizOn gratis of bekijk de API-documentatie om vandaag nog te beginnen met het bouwen van je backend.


Bron: How to edit the values of a blueprint class that is a child and that is already in use by the player?