Why
I wanted modding support in some private Godot game projects, and I built this from scratch specifically to understand GDExtension rather than drop in an existing solution. GDExtension is Godot 4's native plugin system — it lets you write shared libraries in C or C++ that integrate directly into the engine as first-class nodes and classes, no engine recompile required. Lua is a natural fit for game scripting: small, embeddable, fast enough, and widely understood by modders.
The implementation is C++ using the godot-cpp bindings submodule — GDExtension classes have to inherit from Godot's C++ base classes. Lua 5.4.8 is embedded directly (its source lives in the repo, which is why the language stats skew heavily toward C), and its C API calls cleanly from C++ via extern "C" linkage. No external runtime dependency. The bridge exposes a LuaBridge node that GDScript can use to load and run Lua scripts, pass data back and forth, and hook into the mod lifecycle. Mods are structured as directories with a JSON manifest and an entry script.
Architecture
The core problem is type conversion between Godot's Variant system and Lua's stack-based dynamic typing. Every value crossing the bridge goes through a conversion layer:
void godot_to_lua(lua_State* L, const Variant& value) {
switch (value.get_type()) {
case Variant::Type::STRING:
lua_pushstring(L, value.operator String().utf8().get_data());
break;
case Variant::Type::INT:
lua_pushinteger(L, value.operator int64_t());
break;
case Variant::Type::FLOAT:
lua_pushnumber(L, value.operator double());
break;
case Variant::Type::ARRAY:
push_array_to_lua(L, value.operator Array());
break;
case Variant::Type::DICTIONARY:
push_dict_to_lua(L, value.operator Dictionary());
break;
default:
lua_pushnil(L);
}
}
Mods are loaded from a directory, each with a JSON manifest describing the mod and pointing to an entry script. The lifecycle mirrors Godot's own node lifecycle — on_init, on_ready, on_update, on_exit:
{
"name": "ExampleMod",
"version": "1.0.0",
"author": "Example Author",
"description": "An example mod",
"entry_script": "main.lua",
"enabled": true,
"dependencies": []
}
function on_init()
print("initializing")
end
function on_ready()
print("ready")
end
function on_update(delta)
-- called every frame
end
function on_exit()
print("shutting down")
end
Usage
var lua_bridge = LuaBridge.new()
lua_bridge.initialize()
# Run a Lua string
lua_bridge.exec_string("x = 42")
# Exchange globals
lua_bridge.set_global("game_time", 123.45)
var t = lua_bridge.get_global("game_time")
# Call a Lua function
var result = lua_bridge.call_function("calculate_damage", [100, 1.5, 0.2])
# Load all mods from a directory
lua_bridge.load_mods_from_directory("res://mods/")
# Enable/disable at runtime
lua_bridge.enable_mod("ExampleMod")
lua_bridge.disable_mod("DebugMod")
lua_bridge.reload_mod("ExampleMod")
Godot objects can be accessed from Lua side through the bridge functions registered at init:
local player = get_node("/root/Main/Player")
if player then
call_method(player, "set_position", {100, 200})
local health = get_property(player, "health")
connect_signal(player, "damage_taken", "on_player_damaged")
end
function on_player_damaged(amount)
print("took", amount, "damage")
end
Build
# Lua as static library
add_library(lua STATIC ${LUA_SOURCES})
target_include_directories(lua PUBLIC ${LUA_INCLUDE_DIR})
# GDExtension shared library
add_library(lua_bridge SHARED ${EXTENSION_SOURCES})
target_include_directories(lua_bridge PRIVATE
${GODOT_CPP_INCLUDE_DIR}
${GODOT_CPP_GEN_INCLUDE_DIR}
${LUA_INCLUDE_DIR}
)
target_link_libraries(lua_bridge lua godot-cpp)
# Initialize submodules first
git submodule update --init --recursive
# SCons (primary)
scons
# Or CMake
mkdir build && cd build && cmake .. && cmake --build .
# Windows
.\build.bat
Dependencies: Lua 5.4.8 (embedded), godot-cpp submodule (Godot 4.4), CMake or SCons. Windows primary; pre-built DLLs included in demo/bin.