The question that decides whether your Unreal project is pleasant or miserable to work on is not "C++ or Blueprints". It's "where does the line between them go". Get the boundary right and the two halves play to their strengths: C++ does the load-bearing logic and the things that need to be fast or testable, Blueprints do the tuning and the wiring and the bits a designer wants to change at 6pm on a Friday without bothering you. Get it wrong and you end up with either a monolith of C++ nobody but you can touch, or a Blueprint spaghetti so deep that a stack trace is a folk legend.
I've now done this on enough of a project to have opinions, and most of them are about restraint. The mechanism for exposing C++ to Blueprints is well documented and not hard. Deciding what to expose, and how to shape it so a designer can use it without a phone call, is the actual skill. Here's what I've settled on.
the macros, briefly, because you do need to know them
There are really only a handful you reach for constantly, and the documentation buries them under a thousand options you'll never use. The ones that earn their keep:
UCLASS(BlueprintType)
class MYGAME_API UHealthComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Health")
float MaxHealth = 100.f;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Health")
float CurrentHealth = 100.f;
UFUNCTION(BlueprintCallable, Category = "Health")
void ApplyDamage(float Amount);
UFUNCTION(BlueprintImplementableEvent, Category = "Health")
void OnDied();
};
That's most of it. BlueprintType on the class makes it usable as a variable and pin type. UPROPERTY exposes data, and the read/write split matters: MaxHealth is a tuning knob, so it's EditAnywhere, BlueprintReadWrite. CurrentHealth is runtime state I don't want a designer setting by hand, so it's VisibleAnywhere, BlueprintReadOnly. That distinction is half of being a good citizen here. The other half is Category, which is the difference between a tidy details panel and a flat wall of fifty unsorted properties that makes everyone hate the component.
The two function macros are the interesting pair. BlueprintCallable means "C++ provides this, Blueprint calls it": damage application, the logic, lives in C++. BlueprintImplementableEvent is the reverse: C++ declares it and calls it, Blueprint fills it in. OnDied has no C++ body. When health hits zero, C++ fires it, and a designer wires up the ragdoll, the sound, the particle burst, the score change, all the things that are presentation and want iterating on without a recompile.
the boundary, which is the whole post really
The principle I keep coming back to: C++ owns the verbs and the rules, Blueprints own the nouns and the feel. ApplyDamage, the clamping, the death condition, the networking, the maths that must be right, all in C++ where it can be reasoned about and, ideally, tested. The colour of the hit flash, which sound plays, how the camera shakes, the exact curve of the damage falloff: Blueprint, or at least a UPROPERTY a designer can edit.
The failure mode I see most is putting too little in C++. It's tempting, early on, to do everything in Blueprint because the iteration loop is so much faster. No recompile, just hit play. And for prototyping that's correct and I won't argue otherwise. But a core mechanic that lives entirely in Blueprint becomes, at scale, a graph you scroll through with the minimap, where a one-line logic change means hunting through a noodle forest. The moment a piece of logic is load-bearing, the moment getting it wrong is a real bug rather than a tuning miss, it wants to be in C++. You lose the instant iteration. You gain something you can read in six months.
the things that quietly make designers miserable
A few specifics, because the general advice above is easy to agree with and hard to apply.
Expose intentions, not internals. Don't give Blueprint a BlueprintReadWrite on a pointer to some internal manager and expect a designer to wire it sensibly. Give them ApplyDamage(float) and Heal(float). The narrower and more verb-like the surface, the harder it is to misuse, and the less your C++ refactors break their graphs. Every public UPROPERTY is a thing a designer can read in a tooltip and a thing you've promised not to rename casually.
Mind the recompile cost. This is the one that surprised me. Adding or changing a UFUNCTION or UPROPERTY signature touches the generated header, and that can trigger a chunky rebuild and, worse, sometimes shake loose Blueprints that referenced the old shape. So I batch the boundary changes. I decide what a component exposes, get the signatures right, and then iterate on the bodies freely. Churning the exposed interface daily means everyone downstream pays for it with rebuilds and the occasional "why is this node red now".
BlueprintPure is a trap if you lie. Marking a function BlueprintPure tells the graph it has no side effects and can be called whenever, possibly multiple times. If it actually mutates something, you get bugs that are genuinely horrible to track because the call count is non-obvious. Pure is for getters and maths. If it changes state, it's BlueprintCallable with an exec pin and no exceptions.
Categories and tooltips are not optional. meta = (ToolTip = "...") and a sensible Category are the difference between a component a designer can self-serve and one that generates a Slack message every time. Five minutes of metadata saves an afternoon of interruptions. I was sniffy about this at first. I'm not anymore.
a small worked example of getting it right
The health component above is the shape I aim for everywhere. The numbers and the events are a designer's to own. The rule that "damage clamps at zero and fires death exactly once" is mine, in C++, where I can be sure it's true:
void UHealthComponent::ApplyDamage(float Amount)
{
if (CurrentHealth <= 0.f) return; // already dead, do nothing
CurrentHealth = FMath::Max(0.f, CurrentHealth - Amount);
if (CurrentHealth <= 0.f)
{
OnDied(); // designer's Blueprint takes it from here
}
}
A designer never sees the clamp or the once-only guard, and never needs to. They see a tidy event called OnDied and a MaxHealth they can tune per enemy. The boundary does its job: the rule is safe in C++, the feel is free in Blueprint, and neither side has to understand the other's mess.
so, without regret
The regret in the title is real and it's specific: it's the regret of a project where the boundary was drawn by accident, function by function, until nobody could say which half owned what. You avoid it by deciding the line on purpose and defending it. C++ for the rules, the maths, the things that must be correct and testable. Blueprint for the tuning, the presentation, the wiring a non-programmer should own. Keep the exposed surface narrow and verb-shaped, keep it stable so you're not raining rebuilds on everyone, and spend the five minutes on categories and tooltips so the people using your code don't have to come and find you. Do that and the two languages stop being a compromise and start being a genuinely nice way to build a game. I went in expecting to tolerate the split. I've come out rather fond of it.