The mistake I made early with Unreal was treating the C++/Blueprint boundary as a detail to sort out later. It is not a detail. It is the architecture. Get it wrong and you end up with a thousand-node graph that nobody can read, or a C++ codebase that recompiles for forty seconds every time a designer wants to tweak a number. Get it right and the two halves stop fighting.
So here is the rule I settled on after a few months of UE4: C++ owns behaviour, Blueprints own configuration and composition. The moment a Blueprint graph starts making decisions, I've usually got something in the wrong place.
what the macros actually buy you
The whole boundary is controlled by a handful of UPROPERTY and UFUNCTION specifiers, and it pays to know exactly what each one does rather than copying them from a tutorial.
UCLASS()
class MYGAME_API AHealthComponent : public UActorComponent
{
GENERATED_BODY()
public:
// A designer-tunable number. EditAnywhere puts it in the details panel,
// BlueprintReadOnly lets the graph read it but not stomp it at runtime.
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Health")
float MaxHealth = 100.f;
// Behaviour. Designers call it, they don't reimplement it.
UFUNCTION(BlueprintCallable, Category = "Health")
void ApplyDamage(float Amount, AActor* Instigator);
// An event the graph can hook for VFX and sound, fired from C++.
UPROPERTY(BlueprintAssignable, Category = "Health")
FOnHealthChanged OnHealthChanged;
};
BlueprintCallable is the workhorse: it exposes a function the graph can call but keeps the logic in C++. BlueprintReadWrite is the one I've learned to be stingy with, because it lets a graph mutate state I thought I owned, and then a bug lives half in C++ and half in a node nobody told me about. If a value should only ever change through a method, it gets BlueprintReadOnly and a BlueprintCallable setter that can validate.
events, not polling
The bit that made everything click was BlueprintAssignable delegates. Instead of a designer's graph asking "is the player dead yet?" on Tick, C++ broadcasts an event and the graph reacts. Tick is where performance goes to die in Blueprints, and every event you expose is one less reason for a designer to reach for it.
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(
FOnHealthChanged, float, NewHealth);
The DYNAMIC part matters: only dynamic delegates can cross into Blueprints, because they're reflected and serialisable. A plain TMulticastDelegate is faster and fine for C++-to-C++, but the graph can't see it. I keep both kinds around and pick based on who needs to listen.
BlueprintImplementableEvent vs BlueprintNativeEvent
These two are how you go the other way, letting C++ call into Blueprint.
BlueprintImplementableEventdeclares a function with no C++ body that designers fill in. Good for purely cosmetic hooks: "play the hit reaction", whatever that turns out to be.BlueprintNativeEventgives you a C++ default (_Implementation) that a Blueprint can override. Good when there's sensible default behaviour but you want the option to replace it per-actor.
I reach for the native variant more often than I expected. It means the game still works with an empty Blueprint, which makes the C++ testable on its own, and the designer override becomes a deliberate choice rather than a missing implementation that silently does nothing.
the regret list
A few things I now avoid, each one earned the hard way:
Exposing UPROPERTY pointers as BlueprintReadWrite so a graph can reach in and rewire references at runtime. It works right up until something dangles and the editor crashes with a callstack that names none of your files.
Putting heavy loops in a BlueprintCallable that a graph calls every frame. The function-call overhead across the boundary is real, and a tight loop belongs entirely in C++ with a single call from the graph.
Renaming exposed functions and properties without thinking. Blueprints reference C++ symbols by name, and a rename quietly breaks every graph that used the old one, with the breakage showing up as a blank node rather than a compile error. I now treat the exposed surface like a public API, because that's exactly what it is.
The summary I'd give my past self: decide the boundary first, keep the exposed surface small, and let C++ stay in charge of anything that can go wrong. The graph should read like a wiring diagram, not a program. When it starts to read like a program, that's the cue to drag the logic back into C++ where the compiler and the debugger can actually help.