Ramblings of an aging IT geek
← Ramblings of an aging IT geek
gamedev

exposing c++ to blueprints in a way you won't hate in six months

A practical guide to the UFUNCTION and UPROPERTY specifiers that bridge C++ and Blueprints in Unreal, and where the line between the two should actually sit.

A game engine editor showing a Blueprint graph

The mistake I made early on was treating the C++ to Blueprint boundary as an afterthought. You write the gameplay in C++, you slap BlueprintCallable on whatever a designer asks for, and six months later your Blueprint graphs are a tangle of nodes that reach deep into your C++ internals, and you can't change a function signature without breaking three graphs you've forgotten exist. The boundary is the architecture. Treat it like one.

the specifiers that actually matter

The whole bridge runs through a handful of macro specifiers, and most of the regret comes from using the wrong one out of habit. The ones I reach for, and what they're actually for:

  • BlueprintCallable: a function a Blueprint can call. Has side effects, sits on the execution wire with the white pins. This is your verb. "Do the thing."
  • BlueprintPure: a function with no side effects that Blueprint can call without an exec pin. Use it for getters and calculations. Misuse it for something stateful and you'll get baffling bugs because Blueprint may call a pure node more than once per frame, or not at all, depending on how its result is used.
  • BlueprintImplementableEvent: declared in C++, implemented in Blueprint. This is how C++ hands control up to a designer. No body in C++ at all.
  • BlueprintNativeEvent: same idea, but with a C++ default implementation a Blueprint can override. You write a _Implementation function for the fallback.
UFUNCTION(BlueprintCallable, Category = "Combat")
void ApplyDamage(float Amount);

UFUNCTION(BlueprintPure, Category = "Combat")
float GetHealthPercent() const;

UFUNCTION(BlueprintImplementableEvent, Category = "Combat")
void OnDeath();

For properties it's the same discipline. BlueprintReadOnly unless a designer genuinely needs to write the value, and EditAnywhere versus EditDefaultsOnly matters more than it looks: the former lets per-instance overrides leak into your levels, which is occasionally what you want and frequently a source of "why is this one enemy different" mysteries.

A close-up of C++ code on screen

where the line should sit

Here's the principle I wish I'd started with: C++ owns the how, Blueprint owns the which and the when. The expensive, performance-sensitive, easy-to-get-subtly-wrong logic lives in C++, where I have a type system and a debugger and source control that diffs properly. Blueprint composes those pieces and wires up the moment-to-moment design decisions, the tuning, the "play this sound here", the things a designer should be able to change without finding me.

So ApplyDamage is C++, because the damage calculation, the resistances, the clamping, that's logic I want compiled and tested. But when damage is applied, what plays when something dies, which enemies get a special reaction, that's OnDeath firing into Blueprint where it belongs. The designer never sees the damage maths. They see a clean verb and a clean event, and that's the entire interface.

the regret-avoidance rules

A few things that have saved me real pain:

Keep the exposed surface small and stable. Every BlueprintCallable is effectively a public API that designers will build on, and breaking it is a refactor across binary assets you can't grep. Expose the minimum, and once it's exposed, treat its signature as load-bearing.

Use Category properly. It costs nothing and it's the difference between a tidy context menu and a designer scrolling through forty ungrouped nodes muttering about you specifically.

Don't expose internal state directly. Expose intent. A BlueprintReadWrite on your raw health float invites a designer to set it to whatever, bypassing all your careful logic. Give them Heal and ApplyDamage instead, and keep the field private to C++.

Prefer BlueprintNativeEvent over BlueprintImplementableEvent when there's a sensible default. It means the C++ side works on its own, and Blueprint override is opt-in rather than mandatory, so you don't end up with a dozen empty Blueprint events that exist only to satisfy a C++ declaration.

Watch what you expose by reference. Hand a Blueprint a pointer to a UObject and you've given it the ability to poke at that object's exposed surface too, transitively, and your tidy interface quietly grows tentacles you didn't sanction. I try to expose values and intents rather than live objects wherever I can, and where I do hand over an object, I'm deliberate about how much of it is itself exposed.

the renaming problem

The single biggest practical headache with this boundary is that Blueprints reference C++ functions and properties by name, and those references live inside binary assets, not text you can grep or diff. Rename a BlueprintCallable function in C++ and any Blueprint that called it doesn't get a nice compile error, it gets a broken node, and you only find out when you open that asset or the build complains much later. There's a meta = (DeprecatedFunction, DeprecationMessage="...") specifier for retiring functions gracefully, and a redirect mechanism in the config for renames, and learning those early saved me a lot of grief. The lesson underneath is the same as any public API: naming is a commitment, so spend a moment getting the name right before a designer builds on it, because changing it later is genuinely expensive.

a note on performance

There's a real cost to crossing the boundary, and it's worth knowing where it lands. A Blueprint calling into a BlueprintCallable C++ function is cheap. A tight loop inside a Blueprint graph, running every tick, doing maths node by node, is not, because Blueprint is interpreted and the per-node overhead adds up fast. So another part of "where the line should sit" is: if it's hot, it belongs in C++. Let Blueprint make the once-per-event decisions and call down into compiled code for anything that runs often. I've watched a frame budget evaporate into a designer's perfectly reasonable-looking tick graph, and the fix was almost always to lift the loop into a single C++ function and expose it as one node.

was it worth the discipline

Yes, unambiguously. The first project where I took the boundary seriously was the first one where a designer and I could work in parallel without standing on each other. They iterated in Blueprint all afternoon and I refactored the C++ underneath, and as long as the exposed surface held, neither of us broke the other. That's the whole promise of the C++/Blueprint split, and it only pays out if you decide up front what crosses the line and what doesn't. Get that right and Blueprint is a genuine joy. Get it wrong and it's a pile of spaghetti with your name on it.