The mistake I made early on was treating Blueprint exposure as a tick-box exercise: take a C++ class, sprinkle UPROPERTY and UFUNCTION over everything, hand it to a designer, walk away. Three weeks later the Blueprint surface looked like a junk drawer and nobody could tell which knobs were safe to turn.
Exposing C++ to Blueprints in Unreal is easy. Exposing it well is a design decision you make per member, and it's worth making deliberately. Here's roughly where I landed.
Expose intent, not implementation
The base class is a contract. If a property is something a designer should tune, mark it EditAnywhere, BlueprintReadOnly and give it a category. If it's internal bookkeeping, it shouldn't be in the Blueprint at all, no matter how convenient that would be for a quick hack today.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combat")
float BaseDamage = 25.0f;
// Don't do this. Now a designer can stomp your cooldown mid-frame.
UPROPERTY(BlueprintReadWrite)
float CurrentCooldown;
BlueprintReadWrite on internal state is how you end up debugging a state machine that a designer "fixed" in the event graph at 2am. Keep mutable internals private to C++ and expose deliberate setters instead.
Functions: pick the right flavour
The annotations carry real semantics, so choose on purpose:
BlueprintCallablefor things designers call. Side effects allowed.BlueprintPurefor cheap, side-effect-free getters. Don't lie about purity; aPurenode that mutates state or hits disk will be re-evaluated more times than you expect and ruin your day.BlueprintImplementableEventwhen C++ wants to ask Blueprint to do something, leaving the body for the designer.BlueprintNativeEventwhen you want a sensible C++ default that Blueprint can override.
That last pair is the actually-clever bit of the system. Native code calls down into Blueprint, Blueprint optionally calls back up, and you get a clean override point without an interface ceremony.
Categories are not optional
A flat list of forty exposed properties is unusable. Category = "Combat|Damage" gives you nested, collapsible groups in the details panel, and the half hour spent naming categories pays itself back every time someone opens the asset. Treat the category string as part of the API.
The regret test
Before I expose anything now, I ask one question: if a designer sets this to a daft value at the worst possible moment, what breaks? If the answer is "the editor, harmlessly", expose it. If the answer is "a live match, silently", keep it in C++ behind a validated setter and clamp the input.
None of this is exotic. It's just the difference between a Blueprint surface that reads like a deliberate interface and one that reads like everything I forgot to make private. The compiler won't warn you about the second kind, but your designers will.