Strongly-Typed Lua Without the Stack Wrestling
Evolving past the standard C++ API for Lua

When I first started working with an open source system, they used the original C++ Lunar (the raw API) connectors. I got used to it. The raw API worked, though required a lot of orchestration. I stepped away for about a year and, on returning, they’d migrated to Sol(ar) instead. At first, my initial thought was “ugh, all my bindings are broken, all the code is broken”, but after a month, I don’t think I’d ever go back.
What Sol solved was the problem we didn’t fully appreciate until we saw the alternative: the raw Lua C API is a stack-based interface where you push and pop values by index, trust at runtime that types match, and get error messages that tell you something failed somewhere in the virtual machine. It works. The debugging experience is archaeology.
Sol3 wraps that interface with C++ types and template machinery so that mismatches surface at registration time–or at the exact Lua access point–instead of propagating silently through game state. This post covers the patterns that made the most difference in practice, starting with the previous article’s OOP object model in Lua as the conceptual foundation.
What Sol3 Actually Changes
The raw C API asks you to push values manually:
// Raw C API: pushing a struct's fields onto the Lua stack
lua_newtable(L); // push empty table
lua_pushstring(L, "name"); // push key
lua_pushstring(L, ability.name.c_str()); // push value
lua_settable(L, -3); // table["name"] = value
lua_pushstring(L, "damage");
lua_pushinteger(L, ability.damage);
lua_settable(L, -3);
// ... repeat for every field, every time
There’s no enforcement that the Lua script reads damage as an integer. There’s no prevention of the script writing to damage when it shouldn’t. If a field name changes in the C++ struct, the stack push code and every Lua script accessing that field all need updating manually. Typically if you mis-push something, you have a full-stop segfault.
Sol3 replaces this with registered type bindings:
#include <sol/sol.hpp>
struct Ability {
std::string name;
int baseDamage;
float cooldown;
bool isOnCooldown;
bool CanActivate() const { return !isOnCooldown && baseDamage > 0; }
void Activate() { isOnCooldown = true; }
};
sol::state lua;
lua.open_libraries(sol::lib::base, sol::lib::math);
// Register Ability as a usertype: a C++ type accessible from Lua
lua.new_usertype<Ability>("Ability",
"name", &Ability::name,
"baseDamage", &Ability::baseDamage,
"cooldown", &Ability::cooldown,
// sol::readonly: Lua can read this field but cannot write it.
// Attempting assignment from Lua raises a runtime error.
"isOnCooldown", sol::readonly(&Ability::isOnCooldown),
"CanActivate", &Ability::CanActivate,
"Activate", &Ability::Activate
);
On the Lua side, you receive a typed object, not a table with whatever fields the C++ code happened to push:
-- 'ability' is an Ability usertype, not an anonymous table
function onPlayerInput(ability)
if ability:CanActivate() then
ability:Activate()
print(ability.name .. " activated. Damage: " .. ability.baseDamage)
end
-- ability.isOnCooldown = false -- runtime error: read-only property
end
The sol::readonly wrapper isn’t just defensive programming. It enforces the invariant that cooldown state is managed by the C++ ability system, not by scripts. Without it, any script can silently break cooldown logic–and the failure shows up elsewhere, hours later.
Think of It as an Input Model
One of the most common friction points at a C++/Lua boundary is function signatures that grow. In our game engine, a spell-casting function starts with four parameters, gets combat modifiers, then stance flags, then context for environmental effects and the Lua call site becomes positional argument soup where swapping two integers produces wrong behavior, not an error.
The pattern that solves this is a context struct that travels across the bridge as a typed object. If you’ve worked with REST APIs or domain models, think of it the same way you’d think of a strongly-typed input model or request DTO: one object carrying the complete, validated state for a single operation.
struct SpellCastContext {
int spellId = 0;
float posX = 0.0f;
float posY = 0.0f;
int targetId = -1;
int level = 1;
bool isCharged = false;
float casterMana = 0.0f;
// Validation lives in C++, where game rules are authoritative
bool IsValid() const {
return spellId > 0 && level > 0 && casterMana >= 0.0f;
}
};
lua.new_usertype<SpellCastContext>("SpellCastContext",
// sol::constructors<T()>() exposes the default constructor to Lua,
// so scripts can call SpellCastContext.new() to create an instance.
sol::constructors<SpellCastContext()>(),
"spellId", &SpellCastContext::spellId,
"posX", &SpellCastContext::posX,
"posY", &SpellCastContext::posY,
"targetId", &SpellCastContext::targetId,
"level", &SpellCastContext::level,
"isCharged", &SpellCastContext::isCharged,
"casterMana", &SpellCastContext::casterMana,
"IsValid", &SpellCastContext::IsValid
);
lua.set_function("FireSpell", [](const SpellCastContext& ctx) -> bool {
if (!ctx.IsValid()) { return false; }
return SpellSystem::Fire(ctx);
});
The Lua script constructs the context explicitly:
function castSpellOnTarget(spellId, target)
local ctx = SpellCastContext.new() -- typed object, default values from C++
-- Named assignment: order doesn't matter, fields are explicit
ctx.spellId = spellId
ctx.posX = target.position.x
ctx.posY = target.position.y
ctx.targetId = target.id
ctx.level = player.spellLevel
ctx.isCharged = player.isChanneling
ctx.casterMana = player.currentMana
if not ctx:IsValid() then
-- validation failure handled in script; game rules from C++
return false
end
return FireSpell(ctx)
end
Adding a new field to SpellCastContext later doesn’t break callers–unset fields use their default values. The validation check stays in C++ where it has access to game rules. And the script is readable months after it was written because the field names document the intent.
sol::constructors<SpellCastContext()>() line matters if your struct has no default constructor or has a non-trivial one. If C++ can’t default-construct the type, Sol3 won’t expose new() to Lua unless you provide an explicit factory. For types with required fields, a static factory method registered to Lua is often cleaner than a multi-argument constructor.Quick Aside: Simple Input Models
Similar to DTOs, you don’t always need the heavy lift of a full object. Thankfully, you can handle this with typed tables as well. For example, we have a tracking system that takes a fairly robust signature as it’s used to persist a wide array of event types. Rather than a struct for each, we ensure required fields, then serialize the rest as JSON (since we’re getting a table object back, that transition is smooth). Here’s a simplified example:
lua.set_function("TrackEvent", [](const sol::table& eventData) {
// Validate required fields
const std::string eventType = eventData.get_or("eventType", std::string{});
const std::string eventKey = eventData.get_or("eventKey", std::string{});
if (eventType.empty() || eventKey.empty()) {
Logger::Error("TrackEvent missing required fields: eventType or eventKey");
return;
}
// Optional meta data
const std::string source = eventData.get_or("source", std::string{"unknown"});
const std::string sourceId = eventData.get_or("sourceId", std::string{"unknown"});
const uint32 quantity = static_cast<uint32>(std::max(1, eventData.get_or("quantity", 1)));
// serialize the rest!
std::string metaJson;
const sol::object metaObj = eventData["meta"];
if (metaObj.valid() && metaObj.get_type() == sol::type::table) {
// using a json serializer such as nlohmann or other
metaJson = json::serialize(metaObj.as<sol::table>());
}
AnalyticsSystem::Track({.type = eventType, .key = eventKey, .src = source, .sId = sourceId, .qty = quantity}, metaJson);
});
Inheritance at the Bridge
This builds on the same prototype model from the previous Lua post–the Enemy inherits Character, methods live on shared prototypes, __index chains the lookup. Sol3 mirrors that structure at the C++ boundary with sol::base_classes:
struct Character {
std::string name;
int health;
int maxHealth;
virtual void TakeDamage(int amount) {
health = std::max(0, health - amount);
}
virtual bool IsAlive() const { return health > 0; }
virtual int GetExperienceValue() const { return 0; } // base returns 0
};
struct Enemy : Character {
int armor;
int experienceValue;
void TakeDamage(int amount) override {
// Armor mitigation first, then delegate to base
int mitigated = std::max(0, amount - armor);
Character::TakeDamage(mitigated);
}
int GetExperienceValue() const { return experienceValue; }
};
// Register base type first--Sol3 requires base before derived
lua.new_usertype<Character>("Character",
"name", &Character::name,
"health", &Character::health,
"maxHealth", &Character::maxHealth,
"TakeDamage", &Character::TakeDamage,
"IsAlive", &Character::IsAlive,
"GetExperienceValue", &Character::GetExperienceValue
);
lua.new_usertype<Enemy>("Enemy",
// sol::base_classes tells Sol3 that Enemy extends Character.
// This enables Lua to access Character's fields/methods on an Enemy,
// and allows polymorphic C++ functions accepting Character* to work
// with Enemy instances passed from Lua.
sol::base_classes, sol::bases<Character>(),
"armor", &Enemy::armor,
"experienceValue", sol::readonly(&Enemy::experienceValue),
"TakeDamage", &Enemy::TakeDamage
);
Lua code can use either type through the shared interface:
function applyCombatDamage(attacker, defender)
if not defender:IsAlive() then return end -- works for Character or Enemy
defender:TakeDamage(attacker.baseDamage)
if not defender:IsAlive() then
local xp = defender:GetExperienceValue() -- works for Character (0) or Enemy (actual XP)
if xp > 0 then
awardExperience(attacker, xp)
end
end
end
Sol3 dispatches virtual methods correctly through the vtable–TakeDamage on an Enemy instance calls the overridden version regardless of how the call is typed in C++.
sol::base_classes requires base types to be registered before derived types. If your type system initializes lazily or in dependency order, explicit registration ordering is required. A flat ordered initialization list (base → derived, no exceptions) is simpler than building a dependency resolution system for what is usually a shallow hierarchy.Protected Calls: A Crash Dump or a Log Entry
This is the section most tutorials I found on Youtube treat as optional. It isn’t.
When a Lua script errors through an unprotected call, that error propagates as a C++ exception into your game process. In many configurations, that means a crash, a dump file, and a player who saw the game disappear without explanation. When we were using the original Lunar bindings, it was a primary driver to extreme levels of process segmentation–isolating issues so if things were bad in one area then only that one area was affected.
sol::protected_function wraps the call in an error boundary:
// Unprotected: if the Lua function errors, this throws in C++
sol::function onSpawn = lua["onEnemySpawn"];
onSpawn(enemyInstance, spawnPoint); // could crash your process
// Protected: error stays contained, you decide what to do
sol::protected_function onSpawn = lua["onEnemySpawn"];
auto result = onSpawn(enemyInstance, spawnPoint);
if (!result.valid()) {
sol::error err = result;
// Log with context, continue running
Logger::Error(
"Script error in onEnemySpawn for enemy '{}': {}",
enemyInstance.name,
err.what()
);
// Game continues. Player sees nothing. You get a log entry.
}
The tradeoff is overhead–each protected call has additional branching compared to an unprotected one. For systems where Lua drives per-frame AI or entity behavior, protected calls on every frame update may add measurable cost. The pattern that works here: validate scripts at load time (run them once through a protected call during initialization), then use unprotected calls for the high-frequency frame path on verified scripts. Reserve protected calls for event callbacks, player-visible interactions, and anything invoked from input handling, where a crash would be directly user-visible.
// Initialization: protected validation
bool LoadScript(sol::state& lua, const std::string& path) {
auto result = lua.safe_script_file(path);
if (!result.valid()) {
sol::error err = result;
Logger::Error("Script load failed: {} -- {}", path, err.what());
return false;
}
return true;
}
// Runtime: unprotected for verified, high-frequency callbacks
sol::function onEnemyUpdate = lua["onEnemyUpdate"];
// ... validated at load, trust it in the frame loop
// Runtime: protected for event callbacks and input-driven calls
sol::protected_function onPlayerSpell = lua["onPlayerSpellCast"];
auto res = onPlayerSpell(ctx);
if (!res.valid()) { /* log, handle gracefully */ }
The goal is that a broken Lua script produces a log entry your team can act on–not a crash report from a player. This has been extremely helpful for net new development and for debugging player-reported issues in the wild. It’s not a silver bullet–a script that errors every frame will still cause performance issues, but at least you have the logs to know what’s going wrong and where to start fixing it.
Handling Optional Fields
Lua’s nil is not C++’s nullptr, and the mismatch creates problems when scripts legitimately omit optional parameters. sol::optional<T> handles the case cleanly:
// Area spells have no single target; the targetId is optional
lua.set_function("CastAreaSpell",
[](const SpellCastContext& ctx, sol::optional<int> targetOverride) {
// value_or: use targetOverride if present, ctx.targetId otherwise
int finalTarget = targetOverride.value_or(ctx.targetId);
return SpellSystem::FireArea(ctx, finalTarget);
}
);
CastAreaSpell(ctx, specificTarget) -- passes an int
CastAreaSpell(ctx, nil) -- passes nothing; sol::optional handles this
Without sol::optional, passing nil where an int is expected throws a Sol3 type error at the call site. That’s actually better than the silent nil-propagation you’d get from an anonymous table, but sol::optional gives you the control to distinguish between “no value provided” and “wrong type provided.”
Seeing the Light
The registration-time binding does more than save keystrokes. It moves the failure point. When a field name changes in C++, the bound pointer-to-member fails at compilation. When a Lua script accesses a property that isn’t in the usertype registration, Sol3 raises an error at the access point with a clear message, not somewhere downstream after nil has propagated through four layers of game state.
That shift–from runtime-discoverable to registration-time or access-point errors–is where the real value is. The code isn’t shorter. The failures are earlier and more informative, and that’s what makes a scripting boundary you can actually maintain over time.







