This article focuses on how C++ calls Lua in Unreal Engine. The core idea is to use UnLua to forward UFunction calls into the Lua virtual machine, which helps solve hot reload, logic decoupling, and the complexity of cross-language calls. Keywords: UnLua, UFunction, Lua stack.
The technical specification snapshot outlines the setup
| Parameter | Description |
|---|---|
| Primary languages | C++, Lua |
| Runtime environment | Unreal Engine + UnLua |
| Core protocols/mechanisms | UE reflection system, UFunction dispatch, Lua C API |
| Typical invocation patterns | BlueprintImplementableEvent, BlueprintNativeEvent, UnLua::Call, lua_pcall |
| Project traction | The original page shows 255 views, 3 likes, and 3 bookmarks |
| Core dependencies | UnLua, lua.hpp, UnLuaBase.h |
UnLua makes C++ and Lua decoupling practical
The key point in UE is not that “C++ directly understands Lua,” but that C++ calls a UFunction managed by the reflection system. During binding, UnLua takes over the execution entry point, pushes arguments onto the Lua stack, invokes the script function, and passes the return value back to C++.
C++ calls a UFunction
-> UnLua forwarding layer
-> Lua virtual machine executes the target function
-> Return value flows back to C++ through the Lua stack
The value of this chain is straightforward: C++ keeps a stable interface, while Lua handles implementation details. That makes it especially suitable for gameplay logic, UI flows, and hot-reloadable modules.
BlueprintImplementableEvent is the most recommended event bridge
With this approach, C++ only declares the interface and does not provide an implementation. After Lua or Blueprint overrides it, the C++ side can invoke it just like a regular member function, which keeps coupling to a minimum.
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// Declare a Blueprint-implementable event and delegate the actual logic to Lua
UFUNCTION(BlueprintImplementableEvent, Category = "Skill")
void OnSkillActivated(int32 SkillId, float CooldownTime);
};
void AMyCharacter::UseSkill(int32 SkillId)
{
// Trigger the event from C++ just like a normal function call
OnSkillActivated(SkillId, 3.0f);
}
This code safely forwards a skill activation event from C++ to a Lua implementation.
local MyCharacter = UnLua.Class()
function MyCharacter:OnSkillActivated(SkillId, CooldownTime)
-- Lua takes over the concrete gameplay logic
print(string.format("Skill %d activated, cooldown %.1f seconds", SkillId, CooldownTime))
self:StartCooldownTimer(CooldownTime)
end
return MyCharacter
This Lua code overrides the event and executes script logic. It fits scenarios such as skills, quests, and UI notifications.
BlueprintNativeEvent fits interfaces that require default behavior
If the interface must continue to work reliably even when Lua does not override it, choose BlueprintNativeEvent. It allows C++ to provide a default _Implementation while preserving Lua customization.
UFUNCTION(BlueprintNativeEvent, Category = "Combat")
float CalculateDamage(float BaseDamage, int32 ArmorLevel);
float AMyCharacter::CalculateDamage_Implementation(float BaseDamage, int32 ArmorLevel)
{
// Default armor damage reduction logic
float ArmorReduction = ArmorLevel * 5.0f;
return FMath::Max(BaseDamage - ArmorReduction, 0.0f);
}
This code provides a fallback implementation for damage calculation, preventing missing Lua overrides from breaking the logic.
function MyCharacter:CalculateDamage(BaseDamage, ArmorLevel)
-- Reuse the default C++ implementation first, then layer script rules on top
local DefaultDamage = self.Overridden.CalculateDamage(self, BaseDamage, ArmorLevel)
if self:HasBuff("IronSkin") then
return DefaultDamage * 0.5
end
return DefaultDamage
end
This Lua code shows how to call the original C++ implementation inside custom logic. It works well for AI strategies, combat formulas, and rule extensions.
Direct Lua C API usage fits low-level or non-UObject scenarios
When the target function is a Lua global function, a module function, or the current context does not belong to a UObject, you need to use the Lua C API directly. This approach is the most flexible, but it is also the easiest way to introduce stack corruption and error-handling issues.
#include "lua.hpp"
#include "UnLuaBase.h"
void AMyManager::CallLuaGlobalFunction()
{
lua_State* L = UnLua::GetState();
if (!L) return;
lua_getglobal(L, "OnGameEvent");
if (!lua_isfunction(L, -1))
{
lua_pop(L, 1); // Clean up the stack if the value is not a function
return;
}
lua_pushstring(L, "PlayerDied"); // Push the event name
lua_pushinteger(L, 1001); // Push the player ID
lua_pushnumber(L, 3.14); // Push a numeric argument
lua_pushboolean(L, true); // Push a boolean argument
int Result = lua_pcall(L, 4, 1, 0); // Invoke and expect 1 return value
if (Result != LUA_OK)
{
const char* ErrorMsg = lua_tostring(L, -1);
UE_LOG(LogTemp, Error, TEXT("Lua call failed: %s"), UTF8_TO_TCHAR(ErrorMsg));
lua_pop(L, 1); // Pop the error message
return;
}
if (lua_isinteger(L, -1))
{
int ReturnValue = lua_tointeger(L, -1);
UE_LOG(LogTemp, Log, TEXT("Lua returned: %d"), ReturnValue);
}
lua_pop(L, 1); // Clean up the return value
}
This code walks through the full process of retrieving the Lua state, pushing values onto the stack, invoking the function, reading the return value, and cleaning up the stack.
UnLua helper APIs further reduce boilerplate
If an object is already bound to Lua, prefer UnLua wrapper functions instead of manually writing lua_getglobal and lua_pcall every time. This approach is shorter, safer, and easier to maintain.
#include "UnLuaBase.h"
void AMyActor::NotifyLuaSide()
{
lua_State* L = UnLua::GetState();
if (!L) return;
if (UnLua::IsUObjectBound(this))
{
// Call a Lua method on the bound object
UnLua::Call(L, this, "OnCppNotify", 100, "hello");
}
}
This code calls a Lua instance method on a bound object and fits regular object-to-script communication.
// Call a function in a module table
UnLua::CallTableFunc(L, "Utils.MathHelper", "Clamp", Value, MinVal, MaxVal);
This code directly calls a Lua module function and fits utility classes or shared logic.
Choosing the right invocation pattern matters more than merely knowing how to call Lua
From an engineering perspective, the recommendation order is clear: use BlueprintImplementableEvent first, then BlueprintNativeEvent, then UnLua helper APIs, and only use the Lua C API as a last resort.
The engineering trade-offs across the four approaches are clear
| Approach | Coupling | Type safety | Default implementation | Best-fit scenarios |
|---|---|---|---|---|
| BlueprintImplementableEvent | Low | Strong | No | Pure event notification, hot-reload logic |
| BlueprintNativeEvent | Low | Strong | Yes | Extensible rules that still need fallback behavior |
| UnLua helper APIs | Medium | Medium | Depends on the scenario | Specific object or module calls |
| Lua C API | High | Weak | No | Global functions, low-level control |
There is only one core principle: let C++ define the boundary without directly depending on Lua; let Lua implement change without breaking the main flow. That principle is the key to long-term maintainability in UE + UnLua projects.
Best practices should prioritize both performance and maintainability
Avoid calling Lua at high frequency inside Tick. Prefer event-driven patterns over polling. Keep performance-sensitive logic in C++, and move gameplay configuration, live operations events, and UI flows into Lua so that you balance runtime efficiency with hot-reload flexibility.
UFUNCTION(BlueprintImplementableEvent)
void OnHealthUpdate(float NewHealth);
void AMyCharacter::OnHealthChanged(float NewHealth)
{
// Notify Lua only when the state changes to avoid per-frame cross-language calls
OnHealthUpdate(NewHealth);
}
This code demonstrates a better practice: replacing Tick-based invocation with event-driven notification.
FAQ provides structured answers to common questions
1. Why is it not recommended to use lua_pcall extensively and directly in C++?
Because it requires developers to manually maintain the Lua stack, parameter order, and error handling. That creates high coupling and makes the code error-prone. Unless you are dealing with global functions or non-UObject scenarios, prefer UFunction-based dispatch or UnLua helper APIs.
2. How do you choose between BlueprintImplementableEvent and BlueprintNativeEvent?
If the logic should be fully delegated to Lua with minimal coupling, choose BlueprintImplementableEvent. If you still need default behavior when Lua is missing, choose BlueprintNativeEvent.
3. What is the most common performance pitfall in cross-language calls inside UE projects?
The most common issue is calling Lua too frequently inside Tick. The correct approach is to switch to event-driven invocation and only call into Lua when state changes, skills trigger, or the UI needs to refresh, reducing the cost of crossing VM boundaries.
Core summary: This article reconstructs the core execution path for calling Lua from C++ in UE projects. It systematically explains four approaches—BlueprintImplementableEvent, BlueprintNativeEvent, UnLua helper APIs, and the Lua C API—while covering UFunction forwarding, Lua stack argument passing, return value handling, and practical guidance for decoupling, hot reload, and performance optimization.