Why Lua Is In Your Program
Hello, World
The shortest possible introduction to why Lua is embedded rather than written from scratch: there is no ceremony, and a designer can edit it without a compiler.
#include <iostream>
int main() {
std::cout << "Hello, World!" << std::endl;
return 0;
}print("Hello, World!")Lua has no
main, no includes, no build step and no types to declare — a script is a sequence of statements executed top to bottom. That is the whole reason it ends up inside C++ programs: the compile-link-run loop that costs you minutes costs a level designer nothing, and the script can be reloaded while the game is still running. print writes to stdout with a newline and calls tostring on whatever it is given.What Lua is for
Worth being explicit about the shape of the decision, because "should this be in Lua?" is a question you will answer many times and the answer is usually the same.
// Lua's whole design is "the language you embed in a C or C++ program":
//
// - the reference implementation is ~30k lines of ANSI C
// - the static library is a few hundred kilobytes
// - no dependencies beyond libc
// - the C API is small enough to learn in an afternoon
// - the license is MIT
//
// Which is why it is inside: World of Warcraft, Roblox, Redis, nginx
// (OpenResty), Neovim, Wireshark, Adobe Lightroom, LOVE2D, Defold,
// and a long tail of proprietary engines.
#include <iostream>
int main() {
std::cout << "300KB, no dependencies, MIT" << std::endl;
return 0;
}-- The trade you are making, from the script's side:
--
-- Gained: no compile step, hot reload, memory safety, closures,
-- a designer can edit it, a modder can extend it
-- Lost: static types, your struct layouts, raw pointers,
-- and roughly 30-60x the speed of the C++ you replaced
--
-- The rule almost every engine converges on is the same one:
-- C++ owns the frame budget, Lua owns the decisions.
print("the decisions, not the frame budget")Lua is not competing with C++ for the same work. It exists so the parts of a program that change often — quest logic, item behavior, UI flow, configuration, tools — can change without a rebuild and without a C++ programmer. The performance number is real and mostly irrelevant if you keep Lua out of the inner loop; it becomes very relevant the moment someone writes a per-particle update in it. The rest of this page is about the seam between the two, because that seam is where all the interesting bugs live.
Embedding: the lua_State
lua_State, and wrapping it in RAII
A
lua_State* is the interpreter. Everything else in the C API takes one as its first argument, and the first thing a C++ programmer should do with it is stop handling it by hand.extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
#include <iostream>
#include <memory>
// The C API hands you a raw pointer with a matching close function —
// which is exactly the shape RAII exists for.
using LuaState = std::unique_ptr<lua_State, decltype(&lua_close)>;
LuaState makeLuaState() {
return LuaState(luaL_newstate(), &lua_close);
}
int main() {
LuaState lua = makeLuaState();
luaL_openlibs(lua.get());
luaL_dostring(lua.get(), "greeting = 'hello from Lua'");
lua_getglobal(lua.get(), "greeting");
std::cout << lua_tostring(lua.get(), -1) << std::endl;
return 0; // lua_close runs here, on every path
}-- From the script's side none of that is visible. The interpreter
-- was already running when this file was handed to it.
greeting = "hello from Lua"
print(greeting)The raw pairing is
luaL_newstate() / lua_close(), and forgetting the second on an error path leaks the whole interpreter — so wrap it. std::unique_ptr with a custom deleter is the two-line version above; sol2 does it for you (see below). Note extern "C" around the headers: Lua is a C library and its headers are not always wrapped for C++, and <lua.hpp> — the wrapper some distributions ship — does not exist on every install, including the one this page compiles against. luaL_openlibs is what makes print, string, table and the rest exist; a sandbox opens only the libraries it wants.Running a script and reading a result
The simplest useful thing: run a string of Lua, then read a value back out. Note how much of the C++ column is error handling.
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
#include <iostream>
int main() {
lua_State* lua = luaL_newstate();
luaL_openlibs(lua);
// luaL_dostring returns NON-ZERO on error, and leaves the message
// on the stack. Checking it is not optional.
if (luaL_dostring(lua, "damage = 12 * 3") != LUA_OK) {
std::cerr << "lua error: " << lua_tostring(lua, -1) << std::endl;
lua_close(lua);
return 1;
}
lua_getglobal(lua, "damage");
std::cout << "damage = " << lua_tointeger(lua, -1) << std::endl;
lua_pop(lua, 1);
lua_close(lua);
return 0;
}-- The same computation, as the script sees it. There is no "return"
-- to C++: a global is simply set, and C++ reads it afterwards.
damage = 12 * 3
print("damage = " .. damage)Every entry point into Lua can fail — a syntax error, a runtime error, out of memory — and the C API reports it by return code with the message left on the stack, which is the convention the whole API follows. There is no exception to catch, and the next row explains why that is not merely a style choice. The value comes back through a global here because that is the simplest channel; returning values properly means
lua_pcall and reading its results off the stack.The Virtual Stack
Everything crosses on a stack
The virtual stack is the single hardest thing about the raw C API, and the reason the sol2 section below exists.
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
#include <iostream>
int main() {
lua_State* lua = luaL_newstate();
// There is no way to hand a C++ value to Lua directly. Everything
// goes through a stack, indexed from 1 at the bottom and -1 at
// the top — and YOU are responsible for its depth.
lua_pushinteger(lua, 42);
lua_pushstring(lua, "text");
lua_pushboolean(lua, 1);
std::cout << "depth: " << lua_gettop(lua) << std::endl;
std::cout << "top is a " << luaL_typename(lua, -1) << std::endl;
std::cout << "bottom is " << lua_tointeger(lua, 1) << std::endl;
lua_pop(lua, 3);
std::cout << "after popping: " << lua_gettop(lua) << std::endl;
lua_close(lua);
return 0;
}-- The stack does not exist from Lua's side. It is an artifact of the
-- C boundary, not of the language: a script just has values.
local count = 42
local text = "text"
local flag = true
print("depth: 3")
print("top is a " .. type(flag))
print("bottom is " .. count)
print("after popping: 0")Every value crossing the boundary is pushed onto a per-state stack and referred to by index — positive from the bottom, negative from the top, so
-1 is "the value I just pushed". Nothing checks that you left the stack the way you found it: a function that pushes three values and pops two leaks a slot on every call, and after enough calls the stack overflows. This is the C++ equivalent of manual memory management, applied to a second resource, and it is why almost nobody writes new bindings against the raw API any more.Stack discipline, and a scope guard for it
This is the first place C++ genuinely improves on the C API rather than merely restating it, and it costs six lines.
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
#include <iostream>
// The C fix is to count pushes and pops by hand on every path. The
// C++ fix is to make the stack a resource with a destructor.
class StackGuard {
public:
explicit StackGuard(lua_State* state) : state_(state), depth_(lua_gettop(state)) {}
~StackGuard() { lua_settop(state_, depth_); } // restore, on EVERY path
private:
lua_State* state_;
int depth_;
};
int readGlobalNumber(lua_State* lua, const char* name) {
StackGuard guard(lua); // whatever this function pushes,
lua_getglobal(lua, name); // it leaves no trace
return static_cast<int>(lua_tointeger(lua, -1));
}
int main() {
lua_State* lua = luaL_newstate();
luaL_openlibs(lua);
luaL_dostring(lua, "health = 70");
std::cout << readGlobalNumber(lua, "health") << std::endl;
std::cout << "stack depth after: " << lua_gettop(lua) << std::endl;
lua_close(lua);
return 0;
}-- Nothing to guard. Lua's own values are garbage collected, and a
-- local goes away at the end of its block with no bookkeeping.
health = 70
local function readGlobalNumber(name)
return _G[name]
end
print(readGlobalNumber("health"))
print("stack depth after: 0")A
StackGuard records the depth on entry and restores it in its destructor, so a function cannot leak stack slots no matter which path it returns on — including an early return, and including a thrown exception. This is exactly the pattern std::lock_guard uses, applied to a different resource, and it is the single highest-value thing to add to a hand-written binding layer. Note the one situation it does not save you from, which the errors section covers: a lua_error does not unwind the C++ stack, so this destructor never runs.sol2: What C++ Adds
sol2 makes the stack disappear
This is the row that argues for the whole section. Compare it against the raw-API version four rows up — same work, and every stack operation has vanished.
#include <sol/sol.hpp>
#include <iostream>
#include <string>
int main() {
// sol::state IS the RAII wrapper, and templates do the stack work.
sol::state lua;
lua.open_libraries(sol::lib::base);
lua.script("damage = 12 * 3 name = 'goblin'");
// No pushing, no popping, no indices, no type codes. The
// conversion is chosen by the type you assign to.
int damage = lua["damage"];
std::string name = lua["name"];
std::cout << name << " takes " << damage << std::endl;
return 0;
}-- Unchanged from the script's side. sol2 is a C++ convenience; it
-- adds nothing to and takes nothing from the language.
damage = 12 * 3
name = "goblin"
print(name .. " takes " .. damage)sol2 is a header-only C++ library that wraps the same C API in templates:
sol::state is the RAII lua_State, lua["name"] is a proxy object, and assigning it to a typed variable is what selects the conversion. The stack operations still happen — they are just generated rather than written, and generated correctly, which means no leaked slots and no wrong index. The cost is compile time (sol2 is heavily templated and will noticeably slow a translation unit) and a dependency; the alternative libraries are LuaBridge, which is lighter, and Luau's own bindings if you are on Roblox's fork.Exposing a C++ function to Lua
The raw API needs a function with a fixed signature that reads its arguments off the stack by index. sol2 takes the function you already have.
#include <sol/sol.hpp>
#include <iostream>
#include <string>
int rollDamage(int sides, int count) {
return sides * count; // deterministic, for the example
}
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base);
// An ordinary C++ function. sol2 deduces the signature and
// generates the argument conversion and the return conversion.
lua["rollDamage"] = &rollDamage;
// A lambda works identically, captures and all.
std::string prefix = "log: ";
lua["log"] = [prefix](const std::string& message) {
std::cout << prefix << message << std::endl;
};
lua.script("log('rolled ' .. rollDamage(6, 3))");
return 0;
}-- From Lua these are indistinguishable from Lua functions. There is
-- no marker, no import, and no way to tell they are C++.
function rollDamage(sides, count)
return sides * count
end
local prefix = "log: "
function log(message)
print(prefix .. message)
end
log("rolled " .. rollDamage(6, 3))Under the raw API every exposed function must be
int (*)(lua_State*), read its arguments with luaL_checkinteger and friends, push its results, and return how many it pushed — see /c/lua for that version. sol2 deduces all of it from the C++ signature, including for lambdas with captures, which the raw API cannot express at all without a userdata to hold the closure. Arity and type errors become Lua errors with sensible messages rather than undefined reads. From the script's side there is deliberately no way to tell a C++ function from a Lua one.Binding a C++ Class
Binding a class, and Lua mutating it
This is the thing you actually came here to do, and it is worth noticing that Lua is mutating a real C++ object on the C++ stack.
#include <sol/sol.hpp>
#include <iostream>
#include <string>
struct Player {
std::string name;
int health = 100;
void damage(int amount) { health -= amount; }
bool alive() const { return health > 0; }
};
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base);
// Members become properties, methods become methods.
lua.new_usertype<Player>("Player",
"name", &Player::name,
"health", &Player::health,
"damage", &Player::damage,
"alive", &Player::alive);
Player hero{"Ada", 100};
lua["hero"] = &hero; // a POINTER: Lua does not own it
lua.script("hero:damage(30) print(hero.name, hero.health, hero:alive())");
std::cout << "C++ still sees health = " << hero.health << std::endl;
return 0;
}-- A bound C++ object looks like an ordinary Lua table with methods.
-- The colon is the method-call syntax: hero:damage(30) passes hero
-- as the first argument, exactly like C++'s implicit this.
local Player = {}
Player.__index = Player
function Player.new(name, health)
return setmetatable({ name = name, health = health }, Player)
end
function Player:damage(amount) self.health = self.health - amount end
function Player:alive() return self.health > 0 end
local hero = Player.new("Ada", 100)
hero:damage(30)
print(hero.name, hero.health, hero:alive())
print("C++ still sees health = " .. hero.health)Handing Lua a
Player* gives it a reference to your object, so hero:damage(30) runs the real C++ method and the change is visible in C++ afterwards. The colon in hero:damage(30) is Lua's method syntax — sugar for hero.damage(hero, 30), which is this made explicit exactly as it is in Go, Rust and Python. What sol2 generates underneath is a userdata with a metatable, which is the raw-API mechanism the next section is about. Note the two shapes are not equivalent for ownership, which is the whole of the next section.Letting Lua construct your type
Once Lua can construct your type, objects start being born on the Lua side — which is where the ownership question in the next section comes from.
#include <sol/sol.hpp>
#include <iostream>
#include <string>
struct Vector2 {
double x = 0;
double y = 0;
Vector2() = default;
Vector2(double x_value, double y_value) : x(x_value), y(y_value) {}
double length() const { return std::sqrt(x * x + y * y); }
};
#include <cmath>
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base);
lua.new_usertype<Vector2>("Vector2",
// Overloaded constructors are listed explicitly.
sol::constructors<Vector2(), Vector2(double, double)>(),
"x", &Vector2::x,
"y", &Vector2::y,
"length", &Vector2::length);
lua.script(R"LUA(
local corner = Vector2.new(3.0, 4.0)
print(corner.x, corner.y, corner:length())
)LUA");
return 0;
}-- Lua has no constructors and no "new" keyword. The convention is a
-- plain function on the class table that builds and returns a table.
local Vector2 = {}
Vector2.__index = Vector2
function Vector2.new(x, y)
return setmetatable({ x = x or 0, y = y or 0 }, Vector2)
end
function Vector2:length()
return math.sqrt(self.x * self.x + self.y * self.y)
end
local corner = Vector2.new(3.0, 4.0)
print(corner.x, corner.y, corner:length())sol::constructors<…> lists the signatures to expose, because C++ overloads cannot be deduced from a single function pointer. An object created by Vector2.new(3, 4) lives in Lua-allocated userdata memory and is destroyed by Lua's garbage collector, which calls the C++ destructor through the __gc metamethod — so RAII does still work, just on the collector's schedule rather than at a scope exit. Note the raw string literal with a custom delimiter, R"LUA(…)LUA": a plain R"(…)" ends at the first )" in its content, and print("after collectgarbage()") contains exactly that sequence — so embedding Lua in C++ wants a delimiter the script cannot produce.Ownership Across the Boundary
Who owns the object
This is the most dangerous thing on the page, and the syntax gives you no hint which of the three you are looking at.
#include <sol/sol.hpp>
#include <iostream>
#include <memory>
#include <string>
struct Resource {
std::string name;
explicit Resource(std::string value) : name(std::move(value)) {}
~Resource() { std::cout << "destroying " << name << std::endl; }
};
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base);
lua.new_usertype<Resource>("Resource", "name", &Resource::name);
std::cout << "-- pointer --" << std::endl;
{
Resource owned("cpp-owned");
lua["a"] = &owned; // POINTER: C++ owns it, Lua borrows
lua.script("print(a.name)");
} // destroyed here, by C++
std::cout << "-- value --" << std::endl;
lua["b"] = Resource("lua-owned"); // VALUE: copied in; the TEMPORARY is
lua.script("print(b.name)"); // destroyed right after the copy
std::cout << "-- shared --" << std::endl;
lua["c"] = std::make_shared<Resource>("shared");
lua.script("print(c.name)");
std::cout << "-- end of main --" << std::endl;
return 0;
}-- Lua cannot tell the three apart, and that is the danger: the same
-- syntax reaches a borrowed pointer, an owned copy, and a shared_ptr.
print("-- pointer --")
local a = { name = "cpp-owned" }
print(a.name)
print("-- value --")
local b = { name = "lua-owned" }
print(b.name)
print("-- shared --")
local c = { name = "shared" }
print(c.name)
print("-- end of main --")
-- Lua's collector frees its own tables whenever it gets to them.Assigning a pointer lends the object: C++ keeps ownership, and if it outlives the Lua reference you have a dangling userdata that will crash on next access — the classic embedding bug. Assigning a value copies it into Lua-owned memory, which is safe and costs a copy. Assigning a
shared_ptr shares it, which is usually what you want for anything with a real lifetime. The rule that prevents most incidents: never hand Lua a raw pointer to something with a shorter lifetime than the lua_State, and prefer shared_ptr when you are not certain.The collector runs the destructor, eventually
A C++ object living in Lua memory keeps its destructor, and loses the one guarantee you rely on most: when it runs.
#include <sol/sol.hpp>
#include <iostream>
#include <string>
struct Handle {
std::string name;
explicit Handle(std::string value) : name(std::move(value)) {}
~Handle() { std::cout << "closing " << name << std::endl; }
};
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base);
lua.new_usertype<Handle>("Handle",
sol::constructors<Handle(std::string)>(),
"name", &Handle::name);
lua.script(R"LUA(
do
local file = Handle.new("scoped")
print("using " .. file.name)
end
-- `file` is unreachable here, but NOT yet destroyed.
print("out of scope, destructor has not run")
collectgarbage()
print("after collectgarbage()")
)LUA");
return 0;
}-- Lua 5.3 has no deterministic destruction at all: a value becomes
-- unreachable and the collector gets to it whenever it gets to it.
do
local file = { name = "scoped" }
print("using " .. file.name)
end
print("out of scope, and nothing has been destroyed")
-- collectgarbage() would force a cycle here — but a plain table has no
-- destructor to run, so there would be nothing to observe. Only userdata
-- (and, since 5.2, tables with a __gc metamethod) are finalized at all.
-- This page's engine, Fengari, does not implement collectgarbage.
print("a table has no destructor; only userdata does")
-- Lua 5.4 added to-be-closed variables (local x <close> = ...) which
-- ARE deterministic — but the browser here runs 5.3.Leaving scope in Lua makes a value unreachable, not destroyed — the destructor runs when the collector next sweeps, which may be many frames later or not before the program exits. For a plain data type that is fine; for anything holding a file handle, a socket, a GPU resource or a lock it is a leak with a long fuse. The two practical answers are to keep such resources owned by C++ and lend Lua a handle, or to give the type an explicit
close() method and make calling it the script's job. Lua 5.4's <close> attribute finally provides deterministic cleanup, but 5.3 is still extremely widely embedded. Note also that only userdata — and, since 5.2, tables carrying a __gc metamethod — are finalized at all, so a plain table simply disappears. The C++ column forces a collection to make the destructor visible; the Lua column cannot, because the engine behind this page does not implement collectgarbage.Errors Across the Boundary
lua_error does not run your destructors
If you read one row on this page for safety rather than convenience, read this one. It is silent, it is platform-dependent, and it leaks.
extern "C" {
#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
}
#include <iostream>
// THE most dangerous interaction between C++ and Lua. Lua is C, and
// when compiled as C it reports errors with longjmp — which does NOT
// unwind the C++ stack, so every destructor between here and the
// pcall is SKIPPED.
struct Noisy {
~Noisy() { std::cout << "destructor ran" << std::endl; }
};
int risky(lua_State* lua) {
Noisy guard; // would leak if luaL_error longjmps
// luaL_error(lua, "boom"); // <- destructor SKIPPED if it does
lua_pushstring(lua, "survived");
return 1;
}
int main() {
lua_State* lua = luaL_newstate();
luaL_openlibs(lua);
lua_pushcfunction(lua, risky);
lua_setglobal(lua, "risky");
luaL_dostring(lua, "print(risky())");
lua_close(lua);
return 0;
}-- From Lua this is just an error, caught with pcall. The script has
-- no way to know it may be tearing through C++ frames.
local function risky()
return "survived"
end
local ok, result = pcall(risky)
print(result)
-- error("boom") here would unwind Lua's own frames cleanly; it is
-- only the C++ frames underneath that are at risk.
print("pcall returned ok = " .. tostring(ok))Lua raises errors with
longjmp when built as C, and longjmp past a C++ frame with a non-trivial destructor is undefined behavior — in practice the destructor is skipped and whatever it owned leaks. Build Lua as C++ (compile the Lua sources with a C++ compiler) and it uses real exceptions instead, at which point unwinding is correct and try/catch works; this is what sol2 recommends and what most engines do. The mirror-image rule also holds: never let a C++ exception escape into Lua, because Lua cannot catch it — catch at the boundary and convert to lua_error.pcall and protected calls
Calling into a script is the other direction across the boundary, and an unprotected call that errors will take your process down.
#include <sol/sol.hpp>
#include <iostream>
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base);
// sol2's protected_function returns a result object rather than
// throwing, so a script error is a value you inspect.
lua.script("function risky(n) if n < 0 then error('negative') end return n * 2 end");
sol::protected_function risky = lua["risky"];
sol::protected_function_result good = risky(21);
if (good.valid()) { std::cout << "ok: " << good.get<int>() << std::endl; }
sol::protected_function_result bad = risky(-1);
if (!bad.valid()) {
sol::error problem = bad;
std::cout << "failed: script error" << std::endl;
(void)problem;
}
return 0;
}-- pcall is Lua's try/catch: it calls a function and returns
-- (false, message) instead of propagating the error.
function risky(n)
if n < 0 then error("negative") end
return n * 2
end
local ok, value = pcall(risky, 21)
if ok then print("ok: " .. value) end
local failed, message = pcall(risky, -1)
if not failed then print("failed: script error") endA plain
lua_call propagates an error out of the interpreter, which for an embedded program means abort. lua_pcall — and sol2's protected_function — catches it and hands back a status, which is what you want for anything a modder or designer can edit. The parallel to draw is that pcall is Lua's try/catch, complete with the same temptation to use it for control flow. Lua errors can be any value, not just a string, so an error object with fields is expressible.Tables vs C++ Containers
One data structure for everything
The single biggest simplification in Lua, and the source of its two most common surprises — which the next row covers.
#include <iostream>
#include <map>
#include <string>
#include <vector>
struct Item { std::string name; int cost; };
int main() {
// C++ gives you a type per shape, each with its own guarantees.
std::vector<int> sequence{10, 20, 30};
std::map<std::string, int> lookup{{"gold", 5}};
Item record{"sword", 100};
std::cout << sequence[0] << " " << lookup["gold"] << " " << record.name
<< std::endl;
return 0;
}-- Lua has ONE: the table. Array, map, struct, object, namespace and
-- module are all the same type, distinguished only by how you use it.
local sequence = { 10, 20, 30 } -- array part
local lookup = { gold = 5 } -- hash part
local record = { name = "sword", cost = 100 }
-- And a table can be both at once.
local mixed = { 1, 2, 3, label = "both" }
print(sequence[1], lookup.gold, record.name, mixed[1], mixed.label)A table has an array part and a hash part, and the implementation moves entries between them automatically, so the same value serves as
std::vector, std::map, a struct, an object and a namespace. That is why the language is so small. The costs are that every element is a dynamically-typed TValue (so no contiguous ints and no cache locality), and that the type system cannot distinguish "array of items" from "config record" — a mistake C++ would catch at compile time is a nil at runtime here.One-based indexing, and nil holes
Two traps in one row, and the second one is genuinely undefined behavior in a language that otherwise has almost none.
#include <iostream>
#include <vector>
int main() {
std::vector<int> readings{10, 20, 30};
std::cout << "first: " << readings[0] << std::endl; // ZERO-based
std::cout << "size: " << readings.size() << std::endl;
// Erasing shifts the rest down; there is no such thing as a hole.
readings.erase(readings.begin() + 1);
std::cout << "after erase: " << readings.size() << std::endl;
return 0;
}local readings = { 10, 20, 30 }
print("first: " .. readings[1]) -- ONE-based
print("size: " .. #readings)
-- Assigning nil does NOT shift: it leaves a HOLE, and # is then
-- allowed to return either 1 or 3. Use table.remove to shift.
table.remove(readings, 2)
print("after erase: " .. #readings)Tables are conventionally indexed from 1, which every library and every
for i = 1, #t loop assumes — so an off-by-one when translating C++ index arithmetic is the commonest porting bug. Worse: #t is only defined for a table with no holes, and setting readings[2] = nil creates one, after which the length operator may legitimately return 1 or 3. Use table.remove, which shifts. And note the consequence for the boundary: a Lua array arriving in C++ must be read with ipairs or a counted loop, never by trusting # on data a script produced.Reading a Lua table into a C++ container
Getting structured data out of a script is the most common thing a binding layer does, and sol2 makes it a conversion rather than a loop.
#include <sol/sol.hpp>
#include <iostream>
#include <string>
#include <vector>
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base);
lua.script("spawns = { 'goblin', 'orc', 'troll' } config = { hp = 20, speed = 3 }");
// sol2 binds std::vector as USERDATA by default, so pulling a Lua
// TABLE into one must say so. Without as_table_t this COMPILES and
// then aborts at runtime: "expected userdata, received table".
auto spawns = lua["spawns"].get<sol::as_table_t<std::vector<std::string>>>();
for (const std::string& name : spawns) { std::cout << name << " "; }
std::cout << std::endl;
// And an individual field out of a record-shaped table.
int health = lua["config"]["hp"];
std::cout << "hp = " << health << std::endl;
return 0;
}-- The script side is just data. Configuration in Lua rather than
-- JSON or YAML is one of the most common reasons to embed it: the
-- "config file" can compute, branch and reuse.
spawns = { "goblin", "orc", "troll" }
config = { hp = 20, speed = 3 }
for _, name in ipairs(spawns) do io.write(name .. " ") end
print()
print("hp = " .. config.hp)sol::as_table_t is doing real work and leaving it out is a trap: sol2 binds std::vector as a userdata by default — so Lua can hold a real C++ vector — which means a bare std::vector<std::string> spawns = lua["spawns"] compiles cleanly and then aborts at runtime with "expected userdata, received table". as_table_t is how you say "this Lua value is a table, convert it". The raw API version is a lua_next loop with careful stack management. Worth noting for design: a Lua table used as configuration can compute — loops, conditionals, shared constants, functions — which is why so many projects use Lua where they would otherwise have JSON, and is also why loading untrusted config needs a sandboxed state.Functions Both Ways
Calling a Lua function from C++
This is the direction that makes Lua a scripting layer rather than a config format: C++ calls into the script at defined hook points.
#include <sol/sol.hpp>
#include <iostream>
#include <string>
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base);
lua.script(R"LUA(
function onHit(attacker, damage)
return attacker .. " deals " .. damage, damage * 2
end
)LUA");
// Lua functions return MULTIPLE values; sol2 unpacks them into
// a std::tuple.
sol::function onHit = lua["onHit"];
std::tuple<std::string, int> result = onHit("goblin", 12);
std::cout << std::get<0>(result) << std::endl;
std::cout << "doubled: " << std::get<1>(result) << std::endl;
return 0;
}function onHit(attacker, damage)
return attacker .. " deals " .. damage, damage * 2
end
-- Multiple returns are built into the language, not built out of a
-- tuple type — and the caller decides how many it wants.
local message, doubled = onHit("goblin", 12)
print(message)
print("doubled: " .. doubled)Multiple return values are a Lua language feature with no C++ equivalent, and sol2 maps them onto
std::tuple. The asymmetry to remember is that Lua discards extras and fills missing arguments with nil silently, so a hook whose signature drifted will not error — it will quietly receive nil and probably do nothing. That is a good reason to validate hook arguments on the Lua side, or to pass a single table rather than a positional list, which is what most engines settle on.Closures
Closures are the feature a C++ programmer most often ends up envying here, because Lua's cannot dangle.
#include <iostream>
#include <functional>
int main() {
int running = 0;
// A lambda with an explicit capture list. Capturing by reference
// and outliving the scope dangles.
auto add = [&running](int value) { running += value; return running; };
std::cout << add(3) << " " << add(4) << std::endl;
return 0;
}-- Lua has had closures since 1993, with no capture list: an inner
-- function captures the upvalue itself, and the collector keeps it
-- alive as long as the closure is reachable.
local function makeCounter()
local running = 0
return function(value)
running = running + value
return running
end
end
local add = makeCounter()
print(add(3), add(4))A Lua closure captures the variable (an "upvalue"), not a copy, and the garbage collector keeps it alive as long as any closure referencing it is reachable — so returning a closure over a local is ordinary and safe, where the C++ equivalent with
[&] is a dangling reference. Two closures created in the same scope share the upvalue, which is how you build a counter with separate increment and read functions. For the boundary: a Lua closure handed to C++ is a value you must anchor in the registry or in a sol::function, or the collector may take it.Strings & Numbers
Strings are immutable and interned
Lua strings are immutable and — for short ones — interned, which makes comparison free and concatenation expensive.
#include <iostream>
#include <string>
int main() {
std::string greeting = "hello";
greeting[0] = 'H'; // mutable in place
greeting += ", world";
std::string other = "H" "ello, world";
std::cout << greeting << " " << greeting.size() << std::endl;
std::cout << std::boolalpha << (greeting == other) << std::endl;
return 0;
}local greeting = "hello"
-- greeting[1] = "H" -- no such thing: strings are IMMUTABLE
greeting = "H" .. greeting:sub(2)
greeting = greeting .. ", world"
local other = "Hello, world"
print(greeting, #greeting)
-- Short strings are INTERNED, so equality is a pointer compare
-- and two equal strings really are the same object.
print(greeting == other)Because every distinct short string exists exactly once in a
lua_State, equality is a pointer comparison rather than a memcmp, and using strings as table keys is cheap. The flip side is that a = a .. b in a loop is O(n²), exactly as it is with std::string but for a different reason — each step allocates and interns a whole new string. The idiom is table.concat, which is Lua's ostringstream. Note that Lua strings are byte strings with no encoding attached, like std::string and unlike Rust's or Swift's, and may contain embedded zeros.One number type, with two subtypes
Lua 5.3 split
number into integer and float subtypes, and the division operator is where a C++ programmer gets caught.#include <cstdint>
#include <iostream>
int main() {
// A type per width and per signedness, chosen at declaration.
std::int32_t small = 7;
double approximate = 7;
std::cout << 7 / 2 << " " // integer division: 3
<< 7.0 / 2 << std::endl; // floating division: 3.5
std::cout << "integer float" << std::endl;
std::cout << small + approximate << std::endl;
return 0;
}-- One type, "number", with integer and float SUBTYPES since 5.3.
local small = 7
local approximate = 7.0
print(7 // 2, 7 / 2) -- // floors, / ALWAYS produces a float
print(math.type(small), math.type(approximate))
print(small + approximate) -- 14: int + float is a float/ in Lua always produces a float, so 7 / 2 is 3.5 and even 6 / 2 is 3.0 rather than 3 — floor division is the separate // operator. That difference bites when a computed value becomes a table index or is passed to C++ expecting an integer. math.type distinguishes the subtypes. Before 5.3 there was only a double, which is why a lot of embedded Lua is still configured with LUA_32BITS or a single numeric type; check what your engine builds.Metatables vs Operator Overloading
Metatables are operator overloading at runtime
Lua has operator overloading, and it is attached to a value rather than declared on a type — which is both more flexible and less checkable.
#include <iostream>
class Money {
public:
explicit Money(int cents) : cents_(cents) {}
Money operator+(const Money& other) const { return Money(cents_ + other.cents_); }
int cents() const { return cents_; }
private:
int cents_;
};
std::ostream& operator<<(std::ostream& stream, const Money& money) {
return stream << "Money(" << money.cents() << ")";
}
int main() {
Money total = Money(150) + Money(275);
std::cout << total << std::endl;
return 0;
}-- A metatable attaches behavior to a value at RUNTIME. __add is +,
-- __tostring is operator<<, __index is member lookup.
local Money = {}
Money.__index = Money
function Money.new(cents) return setmetatable({ cents = cents }, Money) end
function Money.__add(left, right) return Money.new(left.cents + right.cents) end
function Money.__tostring(self) return "Money(" .. self.cents .. ")" end
local total = Money.new(150) + Money.new(275)
print(tostring(total))A metatable is an ordinary table whose specially-named fields the interpreter consults:
__add, __eq, __lt, __len, __call, __tostring, __index, __newindex, __gc. Because it is set at runtime with setmetatable, the same table can gain or change behavior later — which is how Lua does prototypes, and how sol2 implements bound C++ types. __index is the important one: it is consulted when a key is missing, so pointing it at a class table is what makes method lookup and inheritance work.__index, and prototype inheritance
Lua has no classes and no inheritance keyword. What it has is a lookup rule, and everything object-shaped is built out of it.
#include <iostream>
#include <string>
class Entity {
public:
virtual ~Entity() = default;
virtual std::string describe() const { return "an entity"; }
};
class Player : public Entity {
public:
std::string describe() const override { return "a player"; }
};
int main() {
Player player;
Entity& asEntity = player;
std::cout << asEntity.describe() << std::endl; // virtual dispatch
return 0;
}-- Inheritance is a CHAIN of __index lookups, resolved at call time.
local Entity = {}
Entity.__index = Entity
function Entity.new() return setmetatable({}, Entity) end
function Entity:describe() return "an entity" end
local Player = setmetatable({}, { __index = Entity }) -- Player's fallback
Player.__index = Player
function Player.new() return setmetatable({}, Player) end
function Player:describe() return "a player" end
local player = Player.new()
print(player:describe()) -- found on Player
print(Entity.describe(player)) -- the "base" version, called explicitlyWhen a key is missing from a table, Lua consults its metatable's
__index — and if that is another table, the search continues there, which chains into inheritance. Every lookup is dynamic, so there is no vtable and no compile-time check: a misspelled method is nil and calling it raises "attempt to call a nil value", which is the commonest Lua runtime error. There is also no super; calling the base version means naming it, as above. For the boundary this matters because sol2's sol::base_classes maps C++ inheritance onto exactly this chain.Coroutines
Coroutines, thirty years early
This is the feature that makes Lua so well suited to game logic, and it predates C++'s equivalent by two decades.
#include <iostream>
#include <vector>
// C++20 has co_await and the machinery to build on, but std::generator
// only arrived in C++23 and needs the promise plumbing. The ordinary
// answer for a sequence is still to build it eagerly.
std::vector<int> fibonacciUpTo(int count) {
std::vector<int> values;
int previous = 0;
int current = 1;
for (int index = 0; index < count; index += 1) {
values.push_back(previous);
int next = previous + current;
previous = current;
current = next;
}
return values;
}
int main() {
for (int value : fibonacciUpTo(8)) { std::cout << value << " "; }
std::cout << std::endl;
return 0;
}-- Lua has had asymmetric coroutines since 5.0 (2003). The function
-- keeps its locals and its position across a yield.
local function fibonacci()
local previous, current = 0, 1
while true do
coroutine.yield(previous)
previous, current = current, previous + current
end
end
local generator = coroutine.create(fibonacci)
for _ = 1, 8 do
local _, value = coroutine.resume(generator)
io.write(value .. " ")
end
print()A coroutine suspends with its locals and instruction pointer intact, which is exactly what a quest script, a cutscene or an AI behavior needs:
walkTo(door); yield(); openDoor(); wait(2) reads as straight-line code and runs across many frames. That is why engines expose a scheduler that resumes coroutines each tick. For the boundary, the constraint to know is that you cannot yield across a C function call in the general case — if C++ called into Lua which then tries to yield past that frame, older Lua raises "attempt to yield across a C-call boundary"; 5.2 added continuation functions to make it possible, and they are fiddly.Performance & LuaJIT
The boundary is the cost, not the language
The performance advice for embedded Lua is almost entirely about the shape of the interface, not about the speed of the interpreter.
#include <sol/sol.hpp>
#include <iostream>
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base);
lua["addOne"] = [](int value) { return value + 1; };
// WRONG SHAPE: 10,000 boundary crossings to do 10,000 additions.
lua.script(R"LUA(
local total = 0
for i = 1, 10000 do total = addOne(total) end
print("crossing per item: " .. total)
)LUA");
// RIGHT SHAPE: one crossing, all the work on one side.
lua["addRange"] = [](int count) { return count; };
lua.script("print('one crossing: ' .. addRange(10000))");
return 0;
}-- The same rule stated from the script's side: call into C++ for
-- BULK operations, not per element.
local total = 0
for i = 1, 10000 do total = total + 1 end
print("crossing per item: " .. total)
print("one crossing: " .. 10000)
-- Pure Lua arithmetic is roughly 30-60x slower than C++, and that
-- is usually fine. Ten thousand boundary crossings per frame is not.A crossing costs argument conversion, a stack push per value, an interpreter dispatch and the reverse on return — on the order of a hundred nanoseconds, which is nothing once and everything a hundred thousand times a frame. So the rule is the same one every FFI has: cross rarely, with a lot of work. Design the API around bulk operations (
spawnWave(config), not spawn() in a loop), pass a table rather than a long argument list, and keep per-entity per-frame updates in C++ with Lua deciding policy. Profilers to know: LuaJIT's -jv, and any engine's own script timing.LuaJIT, and why so many projects are stuck on 5.1
Before writing any binding layer, find out which Lua your engine embeds — the answer changes what the scripts may use and whether you need a binding layer at all.
// The choice you will actually face when embedding:
//
// PUC Lua 5.4 — the reference implementation, current, portable,
// ~30k lines of C, easy to build anywhere
// LuaJIT — a tracing JIT, often 10-50x faster than PUC Lua on
// numeric code, plus a superb FFI that calls C
// functions with NO binding layer at all
//
// The catch: LuaJIT targets the Lua 5.1 language, plus a few 5.2
// features. It is not going to 5.4. So a project that needs LuaJIT's
// speed is choosing a 2006 dialect — no integer subtype, no goto, no
// bitwise operators, different varargs handling.
#include <iostream>
int main() {
std::cout << "speed, or a current language. Usually not both."
<< std::endl;
return 0;
}-- What the dialect difference looks like in practice. This file is
-- written to run on 5.1 through 5.4, which is what portable embedded
-- Lua actually looks like.
-- Feature-PROBE rather than compare _VERSION: the answer differs
-- between the Lua your engine embeds and the one running this page.
local hasIntegers = (math.type ~= nil) -- 5.3+
local hasBitwiseOps = (load("return 1 << 1") ~= nil)
print("integer subtype: " .. tostring(hasIntegers))
print("bitwise operators: " .. tostring(hasBitwiseOps))
-- LuaJIT's FFI, which has no PUC equivalent, looks like this:
-- local ffi = require("ffi")
-- ffi.cdef[[ int printf(const char *fmt, ...); ]]
-- ffi.C.printf("no binding layer at all\n")
print("speed, or a current language. Usually not both.")LuaJIT's FFI is the part worth knowing about even if you never use it:
ffi.cdef takes C declarations as a string and calls the functions directly, so there is no stack, no userdata and no sol2 — which is why so many projects tolerate being stuck on the 5.1 dialect. Roblox's Luau is a third option, a 5.1 fork with gradual typing and its own sandbox. The practical advice is to write bindings against the smallest common surface, and to check _VERSION or feature-probe rather than assume, exactly as the Lua column does above.Lua the Language
Globals by default, and why that hurts
This is the thing to configure before letting anyone else write scripts against your engine, because it does not announce itself.
#include <iostream>
int main() {
int count = 0; // a local, and there is no other kind
// without saying so at file scope
// A typo is a compile error: "cnt was not declared in this scope".
count = count + 1;
std::cout << count << std::endl;
return 0;
}-- An undeclared name is a GLOBAL, silently. This is Lua's worst
-- default and the source of its most common bug.
local count = 0
count = count + 1
print(count)
-- A typo does not error — it reads nil and then fails somewhere else:
print(tostring(cnt)) -- nil, not an error
-- The fix is discipline: `local` on everything, and a linter
-- (luacheck) or a strict-mode metatable on _G in development.Assigning to an undeclared name creates a global, and reading an undefined one yields
nil rather than erroring — so a misspelled variable silently reads nil and the failure surfaces somewhere unrelated. Globals are also slower than locals (a hash lookup in _G against a register) and are shared across every script in the state. The standard mitigations are luacheck in continuous integration, and a development-only metatable on _G whose __index and __newindex raise on undeclared access — worth wiring into your engine's debug build.Only nil and false are falsy
A short row with an outsized bug count: Lua's truthiness rule is the opposite of C++'s in the case you will hit most.
#include <iostream>
int main() {
int zero = 0;
const char* empty = "";
// 0 is false, and every non-null pointer is true.
if (!zero) { std::cout << "0 is falsy" << std::endl; }
if (empty) { std::cout << "a valid pointer is truthy" << std::endl; }
return 0;
}local zero = 0
local empty = ""
-- 0 IS TRUE in Lua. So is "". Only nil and false are falsy.
if zero then print("0 is TRUTHY") end
if empty then print("empty string is TRUTHY") end
if not nil then print("only nil and false are falsy") end0 is truthy in Lua, and so is the empty string — only nil and false are falsy. So if count then is true for a count of zero, and the C++ habit of testing a number for truthiness silently changes meaning when ported. The idiom for "present and non-zero" is if count and count > 0 then. This also matters at the boundary: a C++ function returning 0 to signal failure will be read as success by a script testing its result directly, so return nil or false for failure instead.What the seam actually costs
Worth ending on the division of labor, because unlike every other target on this anchor you are not choosing between the two languages — you are drawing a line through one program.
// What stays on the C++ side, and why:
//
// The frame budget, the memory layout, the hardware
// Anything per-entity per-frame
// Anything whose lifetime must be deterministic
// The types Lua borrows — and their lifetimes
//
// What C++ specifically must get right at the seam:
//
// Build Lua as C++ so errors unwind instead of longjmp
// Never lend a raw pointer with a shorter life than the state
// Never let a C++ exception escape into Lua
// Restore the stack on every path (or use sol2)
#include <iostream>
int main() {
std::cout << "the machine, and the lifetimes" << std::endl;
return 0;
}-- What moves to the Lua side, and why:
--
-- Anything that changes weekly: quests, items, balance, UI flow
-- Anything a designer or modder should own
-- Anything worth hot-reloading rather than rebuilding
-- Policy, sequencing, and one-off behavior
--
-- The bargain is unusually good: Lua is small enough to embed without
-- thinking about it, fast enough for decisions, safe enough to hand to
-- someone who is not a C++ programmer, and old enough that the
-- patterns are all well known.
print("the decisions, and the iteration speed")Nothing else on this anchor is like this: Rust, Go and Zig are alternatives to C++, and Lua is a component of a C++ program. So the question is never "should we use Lua instead" but "where should the line go", and the answer that most projects converge on is the one above — C++ owns the machine and the lifetimes, Lua owns the decisions and the iteration speed. Get the four seam rules right and embedded Lua is one of the highest-leverage things you can add to a C++ codebase; get the ownership one wrong and you get crashes that reproduce only under memory pressure.