Posted on

I am building a small game engine in Zig, and at a certain point I had to ask myself a question: do I keep writing gameplay in the same language as the engine, recompiling everything to move a cube, or do I add a scripting layer? I went with the scripting layer, and I picked Lua for it. It is tiny, fast, easy to embed, and games have used it for decades.

Adding Lua is the easy part. The work is in how the engine and Lua talk to each other. A script is useless unless it can reach into the engine: read the input, move a node, ask the scene for its camera. That bridge is called a binding, and writing bindings by hand is some of the most repetitive code you will ever produce.

So this post is about generating that bridge instead of typing it out. I'll start from the bottom, with what a binding really is at the level of the Lua stack, then write a couple by hand to show why that does not scale, and finally let Zig's compile-time reflection do the rest. The part I like is that by the end one description of my API is enough to both bind the functions and generate the autocompletion stubs my editor reads.

Let's start.

Two worlds that do not share a language

As I mentioned, the engine is written in Zig, which is statically typed and compiled, and a Node is a struct with a fixed layout in memory. Lua is dynamically typed and interpreted, and it knows nothing about that struct. The two meet across the Lua C API, and the first thing to understand about that API is that everything goes through a stack.

You do not call a C function from Lua by passing arguments the way you would in C. Lua gives you a small virtual stack. When Lua calls your function, the arguments are already sitting on it. You read them off, do your work, push your return values back, and return one integer: how many values you left up there. That is the whole protocol.

In zlua, the Zig binding library I use, I write that function in a Zig-friendly shape, fn (lua: *Lua) i32, and zlua.wrap turns it into the C-callable function pointer (CFn) that Lua actually expects:

fn myFunction(lua: *Lua) i32 {
    // 1. read arguments off the stack (index 1, 2, 3, ...)
    // 2. do the work
    // 3. push results onto the stack
    return n; // I left n results on the stack
}

Index 1 is the first argument, 2 the second, and so on. Negative indices count from the top, so -1 is whatever is on top right now. Say a script calls node:translate(10, 20, 30). Inside your function the stack looks like this:

index    1       2     3     4      <- count from the bottom
        node    10    20    30
index   -4      -3    -2    -1      <- count from the top

So node is at index 1, the first real argument 10 is at index 2, and the same 10 is at index -3. Reading slot 2 and reading slot -3 give you the same value. Once that clicks, the rest is detail.

A binding is mechanical work on top of this. You pull the Zig-typed arguments out of the stack, call the real Zig function, then push the result back. Converting values between Lua's stack and Zig's types like this has a name: marshalling. A binding is mostly marshalling, and marshalling is the kind of mechanical work a computer should be doing instead of us.

Making a Zig pointer look like an object

Before a script can call methods, Lua needs something to call them on. When a script writes scene:get_name(), what is scene? It cannot be a plain Lua table, because the real scene lives in engine memory and Zig owns it. Lua has to hold a reference to it.

Lua's tool for that is userdata: a block of memory that Lua owns and garbage-collects, but whose contents are opaque to Lua. I do not store the scene itself in there. The engine owns the scene, and its lifetime has nothing to do with Lua's garbage collector. Instead, I store a pointer to it. The userdata is an 8-byte box holding a borrowed *Scene:

fn pushScene(lua: *Lua, scene: *model.Scene) void {
    const ud = lua.newUserdata(*model.Scene, 0); // Lua owns an 8-byte slot
    ud.* = scene;                                 // store the borrowed pointer
    lua.setMetatableRegistry("Scene");            // attach the "Scene" behaviour
}

That last line does the interesting work. A userdata on its own is inert. You cannot index it or call methods on it. A metatable gives it behaviour. When Lua evaluates scene:get_name(), it does not find get_name on the userdata, because there is nothing there. So it looks at the userdata's metatable for a special key called __index. If __index is a table, Lua searches that table for get_name. This is how objects work in Lua. The metatable plays the role a vtable plays in C++.

So binding a class to Lua comes down to this: create a metatable, register some functions into it under their Lua names, and point __index at the metatable itself so method lookup finds them. Here it is for a Scene:

// Create a fresh table, store it in Lua's registry under the name "Scene",
// and leave it on the stack.
try lua.newMetatable("Scene"); // stack: [ mt ]

// Copy the value at index -1 (the metatable we just made) onto the top.
lua.pushValue(-1);             // stack: [ mt, mt ]

// Pop the top value and store it as mt["__index"]; the table is at index -2.
lua.setField(-2, "__index");  // mt.__index = mt   ->  stack: [ mt ]

// ... register each method into mt, then pop mt off the stack ...

Three stack operations happen here, and the same three turn up all over Lua binding code, so they are worth understanding.

newMetatable("Scene") creates a fresh table, registers it under the name "Scene" so I can find it again later, and leaves it on top of the stack. After this line the stack holds one value: the metatable, at index -1.

pushValue(-1) does not invent a value. It copies an existing stack slot to the top. To put a brand-new value on the stack you call a typed push like pushString or pushInteger; pushValue is specifically "duplicate whatever is at this index". I pass -1, the top, so now two copies of the metatable sit on the stack.

Why two? Because the next call eats one. setField(index, key) does t[key] = v, where t is the value at index, v is the value on top, and v is popped afterwards. I want mt.__index = mt, so the metatable has to play both parts: the table t and the value v. After the duplicate, the table is the second slot from the top, which is index -2, and the value to assign is on top. So setField(-2, "__index") pops the top copy and leaves the original metatable behind, now pointing its __index back at itself.

With that, the metatable is wired up.

The honest, painful way

I will write a binding by hand first, because you should feel the problem before automating it away. Here is Scene:get_name():

fn sceneGetName(lua: *Lua) i32 {
    // arg 1 must be a "Scene" userdata
    const scene_ud = lua.checkUserdata(*model.Scene, 1, "Scene");
    
    // unwrap to the borrowed *Scene
    const scene: *model.Scene = scene_ud.*;
    
    // push the name as the result
    _ = lua.pushString(scene.name);
    
    // one value left on the stack
    return 1;
}

Read it against the protocol from before. Argument 1 is the self userdata. checkUserdata pulls it off the stack and also checks that it really carries the "Scene" metatable, so a script that passes the wrong object gets a clean error instead of corrupting memory. We dereference to the real *Scene, push its name, and return 1 because we left one value on the stack.

The code is correct, and it is also ten lines that do almost nothing. I have to write a near-identical ten for get_camera_node, and again for every field of every struct, and again for every method of every type I want to expose. Each one is a place to slip up: read argument 2 instead of 1, forget the return, push an integer where Lua wanted a number. This is plumbing the type system already knows how to write. So I will bring in reflection.

What reflection means in Zig

If you come from C++, the word "reflection" might suggest runtime cost, RTTI, and a bit of magic. In Zig it is none of that. Zig runs ordinary Zig code at compile time, and during that compile-time execution it can inspect types. There is no runtime cost, because by the time the program runs the inspection is long over and only the generated code is left.

I lean on two tools. The first is @typeInfo(T), which returns a value that describes a type: for a struct it lists the fields, for a function it gives the parameter types and the return type, for an integer it gives the bit width. You switch on it like any other tagged union. The second is std.meta, which adds convenience helpers on top, such as std.meta.fields(T) and std.meta.ArgsTuple(F).

An example makes it concrete. Take a small struct and a small function:

// a plain struct with two scalar fields
const Mouse = struct { x: f32, y: f32 };

// a plain function with two arguments
fn add(a: i64, b: i64) i64 { return a + b; }

@typeInfo(Mouse) comes back tagged .@"struct" (the tag is quoted because struct is a keyword), and inside it carries a .fields array. Each field knows its .name ("x", then "y") and its .type (f32). std.meta.fields(Mouse) is just a shortcut to that .fields array, which I can loop over:

inline for (std.meta.fields(Mouse)) |f| {
    // f.name is "x" then "y";  f.type is f32
}

A function reflects the same way. @typeInfo(@TypeOf(add)) is tagged .@"fn", and it gives me .params (each with a .type, here i64 and i64) and .return_type (i64). On top of that, std.meta.ArgsTuple(@TypeOf(add)) turns the parameter list into a tuple type I can fill in and then call:

// a tuple shaped like .{ i64, i64 }
var args: std.meta.ArgsTuple(@TypeOf(add)) = undefined;

args[0] = 2; // first parameter
args[1] = 3; // second parameter

// calls add(2, 3); r == 5
const r = @call(.auto, add, args);

That last move, building an argument tuple from a function's type and calling the function with it, is the trick behind binding functions automatically.

The keyword that ties all this together is inline for. A normal for loops at runtime. An inline for is unrolled at compile time, and its loop variable is a comptime value, so inside the loop you can use it where Zig needs a compile-time constant: a field name, or a type. That is what lets me walk a struct's fields and generate one branch per field.

With that, the hand-written boilerplate can go.

Structs for free

Most of what a script wants from the engine is plain data. Read input.mouse.x, set input.key.w. There is no real method here, only field access. So instead of one getter per field, I write one generic __index that works for any struct T by reflecting over its fields:

fn metaIndex(comptime T: type) zlua.CFn {
    // wrap a plain Zig fn as a Lua-callable CFn
    return zlua.wrap(struct {
        fn index(lua: *Lua) i32 {
            // arg 1: the object, resolved to *T
            const self = lua.checkUserdata(*T, 1, @typeName(T)).*;
            
            // arg 2: the field name Lua asked for
            const key = lua.checkString(2);
            
            inline for (std.meta.fields(T)) |f| {
                // skip "private" fields
                if (comptime f.name[0] == '_') continue;

                if (std.mem.eql(u8, key, f.name)) {
                    // push that one field, by pointer
                    pushFieldValue(lua, f.name, &@field(self.*, f.name));
                    
                    // one value returned to Lua
                    return 1;
                }
            }
            return 0;
        }
    }.index);
}

There are two phases here. The inline for runs at compile time and unrolls into one if per field, so a struct with x, y, z produces three comparisons in the generated code. The std.mem.eql comparison runs at runtime, against the key the script actually asked for. The result is a dispatcher of the same quality I would have written by hand, except I did not write it.

Two details I like. Fields whose name starts with _ are skipped, which is my convention for a private field that Lua should not see. And an unknown key returns 0, meaning "I pushed nothing", which Lua reads as nil. That is exactly the Lua behaviour you would want.

Writing a field needs the mirror image, __newindex, which Lua calls on assignment. It pulls the value off the stack and writes it back into the struct. It has one extra rule: only leaf fields, like a number or a bool, are writable. A whole nested struct is not a writable leaf, so __newindex skips it and the assignment is quietly ignored, the same as writing to an unknown key.

How do we push a field once we have found it? We reflect on the field's type and pick the matching Lua push function:

fn pushFieldValue(lua: *Lua, comptime field_name: []const u8, field_ptr: anytype) void {
    // the field's Zig type
    const F = @TypeOf(field_ptr.*);
    
    // choose a Lua push based on that type
    switch (@typeInfo(F)) {
        .bool => lua.pushBoolean(field_ptr.*),
        .int => lua.pushInteger(@intCast(field_ptr.*)),
        .float => lua.pushNumber(@floatCast(field_ptr.*)),
        .@"struct" => pushStruct(lua, field_ptr),
        else => @compileError("unsupported field type"),
    }
}

Look at the .@"struct" branch. A nested struct is not copied into Lua. It gets wrapped as its own userdata pointing at the sub-struct, with its own metatable. So input.mouse returns a Mouse userdata, and input.mouse.x then indexes into that. The recursion in the data structure becomes recursion in the binding, for free.

The else branch matters too. If I ever add a field of a type the bridge cannot marshal, say an enum or a tagged union, the program does not compile, and the error names the exact field and type. A hand-written binding would have crashed at runtime months later. Here the failure shows up today, at the line. Reflection saves typing, but moving a class of bugs from runtime to compile time is the part I care about more.

Walking the whole type tree

If input.mouse is going to return a Mouse userdata with a working metatable, then Mouse needs a registered metatable too. And Mouse might contain a Position, which needs one as well. So registering a type means registering every struct it contains, all the way down. That is a recursive walk over the type tree, again with inline for:

fn registerStructTree(lua: *Lua, comptime T: type) !void {
    // give T a metatable with the generic __index/__newindex
    try registerStruct(lua, T);
    
    // look at each field of T at compile time
    inline for (std.meta.fields(T)) |f| {
        // skip "private" fields
        if (comptime f.name[0] == '_') continue;
        
        switch (@typeInfo(f.type)) {
            // a struct field: recurse into it
            .@"struct" => try registerStructTree(lua, f.type),
            
            // a scalar field: nothing to register
            else => {},
        }
    }
}

One call, registerStructTree(lua, Input), makes my whole input struct readable and writable from Lua, however deeply it nests. When I add a field to Input in the engine, the script sees it on the next run, and I touch no binding code. The binding follows the types instead of duplicating them, which is what I was after.

Functions for free

At this point, the data side is done. Calling real Zig functions like node:translate(dx, dy, dz) is the part that looks harder: each one has its own arguments and its own return type, so this looks like where I finally have to write the code by hand.

It is not. A function's signature is type information too, and @typeInfo of a function gives me its parameter types and return type. So I can generate the adapter automatically from the function value alone. I keep calling that adapter a marshalling shim. A shim is a thin layer that sits between two sides so they fit together, and this one does the marshalling we named earlier: it reads Lua values off the stack, converts them to Zig types, calls the function, and converts the result back. One small shim per function, written by the compiler.

We already met std.meta.ArgsTuple in the reflection section: it turns a function type into a tuple holding exactly that function's arguments. Here it earns its keep. I declare one of those tuples, fill each slot by pulling the matching value off the Lua stack, then call the function with @call:

fn methodCFn(comptime f: anytype, comptime pullSelf: anytype) zlua.CFn {
    return zlua.wrap(struct {
        fn call(lua: *Lua) i32 {
            // A tuple shaped exactly like f's argument list, e.g. .{ *Node, f32, f32, f32 }.
            var args: std.meta.ArgsTuple(@TypeOf(f)) = undefined;

            // A free function passes null here; a method passes a pullSelf, so it has one leading arg.
            const self_count = if (@TypeOf(pullSelf) == @TypeOf(null)) 0 else 1;
            
            // fill slot 0 with the receiver
            if (self_count == 1) args[0] = pullSelf(lua);

            // reflect on f's signature
            const fi = @typeInfo(@TypeOf(f)).@"fn";
            
            inline for (fi.params[self_count..], self_count..) |param, i| {
                // read Lua stack slot i+1 as param.type
                args[i] = pullArg(lua, param.type.?, i + 1);
            }

            if (fi.return_type.? == void) {
                // call f(args...) and return nothing to Lua
                @call(.auto, f, args);
                return 0;
            } else {
                // call f and push its result
                pushReturnValue(lua, @call(.auto, f, args));
                return 1;
            }
        }
    }.call);
}

This is the function the rest of the binding leans on. The inline for walks the function's parameter list at compile time. For each parameter it emits a pullArg call that reads stack slot i + 1 and converts it to that parameter's exact Zig type. Then @call invokes the real function with the assembled tuple. The void versus non-void check decides whether we return 0 or 1 values to Lua. That is the protocol from the first section, derived from the return type instead of typed out.

pullArg is the inverse of pushFieldValue. It reflects on the wanted type and reads it from the stack:

fn pullArg(lua: *Lua, comptime A: type, idx: i32) A {
    // dispatch on the wanted Zig type
    return switch (@typeInfo(A)) {
        .float => @floatCast(lua.checkNumber(idx)),
        .int => @intCast(lua.checkInteger(idx)),
        .bool => lua.toBoolean(idx),
        // []const u8 <- Lua string
        .pointer => |p| if (p.size == .slice and p.child == u8) lua.checkString(idx),
        else => @compileError("Lua arg unsupported: `" ++ @typeName(A) ++ "`"),
    };
}

To expose Node.translate, a normal Zig method I wrote for the engine that knows nothing about Lua, I write zero lines of marshalling. I point the generator at it and I am done.

The self problem

There is one wrinkle you may already have spotted. A method is called on an object, and that object is called the receiver: in node:translate(...), node is the receiver, the thing the method acts on. A free function like add(x, y) has no receiver; it is just called with its arguments. In Lua the colon is what makes the difference. Writing node:translate(...) passes node as a hidden first argument, so on the stack argument 1 is the receiver and the real arguments start at slot 2. The dot form, node.translate(node, ...), is the same call with the receiver written out by hand.

That is what the pullSelf parameter handles. For a free function I pass null, self_count is 0, and every parameter is pulled as a normal argument. For a method I pass a small function that unwraps the receiver from slot 1 into the right pointer type. For a simple Zig-owned struct that is a one-liner:

fn pullPtrSelf(comptime T: type, comptime name: [:0]const u8) fn (lua: *Lua) *T {
    // an anonymous struct, used only as a namespace
    return struct {
        fn pull(lua: *Lua) *T {
            // arg 1 must be a `name` userdata -> *T
            return lua.checkUserdata(*T, 1, name).*;
        }
    }.pull; // hand back the function itself
}

That struct { fn pull ... }.pull shape deserves a full walk-through, because it looks odd the first time and it shows up all over this code. metaIndex and methodCFn use it too.

Here is the problem it solves. I want pullPtrSelf to return a function, and that returned function needs to know T and name. But Zig functions are not closures: a plain nested function cannot capture the T and name from around it. What Zig does give me is comptime parameters and the fact that a struct is a value I can define on the spot.

So I define an anonymous struct type inside pullPtrSelf, put a function called pull inside it, and return .pull, a reference to that function. The struct holds no data here. It is only a namespace for the function. The point is that T and name are comptime parameters of pullPtrSelf, so when the compiler builds the struct it bakes their concrete values straight into pull. Calling pullPtrSelf(Gui, "Gui") and pullPtrSelf(Probe, "Probe") produces two different struct types, and therefore two different pull functions, each with its own T and name already filled in. Returning .pull hands back one of those concrete functions, ready to be used as an ingredient of a CFn. It is Zig's idiom for a function generated from comptime arguments, the same comptime machinery that powers generic types, and once you recognise it you will spot the same move in metaIndex and methodCFn.

Why make pullSelf a pluggable function instead of hard-coding "grab the pointer from slot 1"? Because not every receiver is a raw pointer.

Store handles, not pointers

For Scene and Input I stored a borrowed *Scene inside the userdata. That is fine for them, because they live as long as the game does. A Node is different. Nodes live in a pool inside the scene, they are created and destroyed during play, and I reload the scene while developing. The moment I reload, every pointer a script is holding dangles. Lua has no idea. It will happily call translate on freed memory, and I get the worst kind of bug, the one that corrupts silently and crashes somewhere unrelated.

A raw pointer is the wrong thing to hand across the scripting boundary. The script outlives the things it points at, and it has no way to know when one has gone away.

The fix is to not store a pointer at all. I store a handle that can tell when it has gone stale. I follow the same approach as Handles are the better pointers by Andre Weissflog: hand out an integer that packs an array index plus a generation tag, keep the real objects in arrays the system owns, and resolve a handle to a pointer only at the moment of use, checking the tag as you go.

My node pool is built on zig-gamedev's zpool, which does exactly that. Every Handle(Node) is an array index plus a cycle counter, and when a slot is freed and reused the cycle bumps, so a handle to the old occupant no longer resolves. The scene then carries one more counter of its own, an epoch that bumps every time the whole scene reloads. The userdata I give Lua bundles all three together:

fn RichHandle(comptime T: type) type {
    return struct {
        // zpool handle: array index + cycle counter
        handle: model.Handle(T),
        
        // the scene this handle belongs to
        scene: *model.Scene,
        
        // the scene's reload counter when we handed this out
        epoch: u32 = 0,
    };
}

Why both a cycle counter and an epoch? They catch different mistakes. zpool's cycle counter catches a single node being destroyed and its slot reused mid-game. The epoch catches a coarser case: when I reload a scene I throw the whole pool away and build a new one, and the fresh pool could hand out the same index and cycle the old one used. Bumping the epoch once per reload makes a handle from the previous scene fail to match.

Every access from Lua goes through one resolver, and that resolver is where the safety lives:

fn resolveNode(node_handle: RichHandle(Node)) !*Node {
    if (node_handle.epoch != node_handle.scene.epoch) {
        // the scene was reloaded since we handed this out
        return error.StaleHandle;
    }
    // zpool resolves index+cycle to a live pointer, or errors if the slot was reused.
    return try node_handle.scene.nodes.getColumnPtr(node_handle.handle, .node);
}

The two checks stack. First the epoch comparison rejects handles from a reloaded scene. Then the try lets zpool run its own cycle check inside getColumnPtr and return an error if the slot was reused. Either way the script gets a clean error instead of a dangling dereference. This is why pullSelf is pluggable: a Node's receiver is not userdata.*, it is "validate the handle, then resolve it to a live pointer, or raise a Lua error". And because pullSelf is just a parameter, the same methodCFn machinery binds both kinds of type without a separate code path: the permanent ones like Scene, where it takes the pointer directly, and the validated ones like Node, where it checks the handle first.

The rule I took away, after it cost me real debugging: a scripting layer must never hold a raw pointer to something whose lifetime it does not control. Hand it a handle it can validate, and validate on every access. The cost is one integer comparison. The other option is a crash you cannot reproduce.

One manifest, one source of truth

All the pieces are here. The last step is to describe the API in one place. I use a small data structure, a list of classes, each with a list of methods, plus a couple of helpers to build each entry. This list is the single description everything else comes from:

const MANIFEST = [_]Class{
    .{ .name = "Node", .methods = &.{
        // rawMethod: I supply both the CFn and the signature string by hand.
        rawMethod("is_valid", zlua.wrap(nodeIsValid), "fun(self: Node): boolean"),
        rawMethod("get_name", zlua.wrap(nodeGetName), "fun(self: Node): string"),
        // autoMethod: the CFn and the signature are both derived from the Zig function.
        autoMethod("translate", model.Node.translate, pullNodeSelf),
        autoMethod("get_child_count", model.Node.getChildCount, pullNodeSelf),
    } },
    // ... Game, Scene, Gui ...
};

Look at the two kinds of entry next to each other, because the contrast is the design. autoMethod takes a real Zig function and derives two things from it by reflection: the marshalling shim, through methodCFn as we built above, and a human-readable signature string, through a small helper called luaSig. We have not used that second thing yet. It describes the function in the notation an editor understands, and it is the subject of the next section. rawMethod is the escape hatch. When a binding needs something reflection cannot express, like the handle validation in nodeIsValid or returning nil for an optional, I hand-write the CFn and supply its signature string myself. Most of the API is autoMethod. The few genuinely special cases are rawMethod. If the generator only automated and gave me no way out, the first irregular function would be a wall; keeping rawMethod a first-class peer of autoMethod means I never hit it.

Binding the whole thing at startup is then a single loop over the manifest that builds each metatable and registers its methods. Add a class, add a line. Add a method, add a line.

The payoff: the editor learns my API

This is the reason the manifest is shaped the way it is.

When you write Lua against an engine, the editor has no idea what node: can do. No autocompletion, no type checking, nothing, because Lua is dynamically typed and your API is invisible to the tooling. The Lua ecosystem's answer is LuaCATS: annotation comments that describe your classes and signatures, which the language server reads to give you completion and diagnostics. They look like this:

---@class Node
---@field translate fun(self: Node, arg1: number, arg2: number, arg3: number)
---@field get_child_count fun(self: Node): number

Normally you write these by hand, and then you have two descriptions of your API, the bindings and the annotations, drifting apart the moment you forget to update one. I am not going to maintain the same thing twice.

So I generate the annotations from the same manifest. Each autoMethod already carries the signature string that luaSig produced. luaSig walks the function's parameter and return types and emits LuaCATS type names: bool becomes boolean, []const u8 becomes string, ?*Node becomes Node?. The same @typeInfo walk that builds the runtime shim builds the documentation. Then a comptime string-concatenation pass turns the whole manifest into one .lua stub file:

fn classesDefs(comptime classes: []const Class) []const u8 {
    // the whole stub, built at compile time
    comptime var s: []const u8 = "";
    
    inline for (classes) |c| {
        // one class header per manifest entry
        s = s ++ "---@class " ++ c.name ++ "\n";
        
        // one ---@field line per method: its Lua name and its luaSig signature
        inline for (c.methods) |m| s = s ++ "---@field " ++ m.lua_name ++ " " ++ m.sig ++ "\n";
        s = s ++ "\n";
    }
    return s;
}

The engine writes this file out, the language server picks it up, and my game scripts get autocompletion and type checking for an API that exists only inside my Zig source. Change a Zig function's signature and the stub changes with it on the next build. There is one source of truth, and it is the Zig code.

That is the real reward of doing bindings through reflection. I wrote less plumbing, yes. But the better part is that the binding, the safety checks, and the editor tooling are all projections of the same types. They cannot drift apart, because there is nothing to keep in sync.

What I would tell myself before starting

Three things, if you are about to do this:

  • Learn the stack protocol first. Every binding, by hand or generated, is "pull arguments off the stack, call the function, push results back". Once that is second nature, the generator is just code that writes the pattern for you. The Lua C API reference documents what every call pushes and pops.
  • Reflect over types, do not duplicate them. @typeInfo, std.meta, and inline for let the compiler write the marshalling, and they turn an unsupported type into a compile error instead of a runtime surprise.
  • Never let a script hold a raw pointer to engine-owned, short-lived data. Hand it a handle it can validate, and check on every access. Handles are the better pointers makes the full case, and zpool is a ready-made implementation.

The engine is still small and the API keeps growing, but the binding layer has stopped being a chore. I add a Zig function, drop one line in the manifest, and it is callable from Lua and autocompleted in the editor.

You tell me what to bind next.

Table of Contents