Darp.Luau is a .NET wrapper around Luau focused on native AOT compatibility, typed value access, and explicit ownership for Luau-backed references.
- NativeAOT first
- Typed reads and writes for tables, functions, userdata, strings, and buffers
- Clear lifetime guarantees both stability and performance
- Simple API through source-generated interceptors
- Host modules and managed userdata
- Coroutines and async managed callbacks
- Support for
linux,windows,macoson bothx64,arm64
using Darp.Luau;
using var lua = new LuauState();
using LuauFunction log = lua.CreateFunction((string message) => Console.WriteLine(message));
lua.Globals.Set("log", log);
using LuauTable config = lua.CreateTable();
config.Set("name", "Ada");
config.Set("enabled", true);
lua.Globals.Set("config", config);
lua.Load(
"""
function add(a, b)
return a + b
end
log(config.name)
result = add(20, 22)
"""
).Execute();
double result = lua.Globals.GetNumber("result");using LuauFunction add = lua.Globals.GetLuauFunction("add");
double sum = add.Invoke<double>(1, 2);
using LuauFunction pair = lua.Globals.GetLuauFunction("pair");
(int total, int delta) = pair.Invoke<int, int>(20, 4);Invoke<TR>(...) converts a single Luau return value to the managed type you ask for and ignores extras. Use Invoke<TR1, TR2>(...), ... for typed multi-return calls, and InvokeMulti(...) for raw LuauValue[] access. The current argument buffer accepts up to 4 arguments per call.
Load(...).Execute(...) follows the same return-shaping pattern for chunk execution: use Load(...).Execute<TR>() for the first typed return value, Load(...).Execute<TR1, TR2>(), ... for typed multi-return calls, and Load(...).ExecuteMulti() for raw LuauValue[] access.
(int total, int delta) = lua.Load("return 20, 4").Execute<int, int>();Use CreateFunction(...) for supported fixed delegate signatures:
using LuauFunction sum = lua.CreateFunction((int a, int b) => a + b);
lua.Globals.Set("sum", sum);
using LuauFunction pair = lua.CreateFunction((int a, int b) => (a + b, a - b));
lua.Globals.Set("pair", pair);Use CreateFunctionManual(...) when you need manual argument parsing, explicit user-facing errors, or a callback shape that the generator-backed path does not support:
using LuauFunction pair = lua.CreateFunctionManual(static args =>
{
if (!args.TryValidateArgumentCount(2, out string? error))
return LuauReturn.Error(error);
if (!args.TryReadNumber(1, out int a, out error) || !args.TryReadNumber(2, out int b, out error))
return LuauReturn.Error(error);
if (a <= b)
return LuauReturn.Error("Expected a to be greater than b");
return LuauReturn.Ok(a + b, a - b);
});CreateFunction(...) must be called directly at the call site so the generator can intercept it. It supports fixed delegate signatures, including supported top-level tuple returns. If you need a shape that is not supported there, use CreateFunctionManual(...).
A callback can return a Task or ValueTask. Run the script with ExecuteAsync(...) or InvokeAsync(...), and it waits for the result without blocking a thread:
using LuauFunction fetch = lua.CreateFunction(
(string url, CancellationToken cancellationToken) => httpClient.GetStringAsync(url, cancellationToken)
);
lua.Globals.Set("fetch", fetch);
int length = await lua.Load("return #fetch('https://example.com')").ExecuteAsync<int>([], cancellationToken);For the script, fetch(url) is a normal call. A CancellationToken parameter is not a Luau argument; it receives the token of the async call.
Methods of [LuauUserdata] types and functions of [LuauModule] types await in the same way. A CreateFunctionManual(...) callback uses the LuauAwaiter from args.TryGetAwaiter(...).
using LuauTable settings = lua.CreateTable();
settings.Set("volume", 0.8);
settings.Set("muted", false);
settings.Set("blob", new byte[] { 1, 2, 3 });
lua.Globals.Set("settings", settings);
using LuauTable roundTripped = lua.Globals.GetLuauTable("settings");
double volume = roundTripped.GetNumber("volume");
bool muted = roundTripped.GetBoolean("muted");
byte[] blob = roundTripped.GetBuffer("blob");Use Get* for required values, TryGet* for optional or external data, and *OrNil when nil is part of the contract.
[LuauUserdata("Player")]
public sealed partial class Player
{
[LuauMember("name", Access = LuauPropertyAccess.ReadOnly)]
public required string Name { get; init; }
[LuauMember("save")]
public Task SaveAsync(CancellationToken cancellationToken) =>
File.WriteAllTextAsync("player.txt", Name, cancellationToken);
[LuauMetamethod(LuauMetamethod.ToString)]
private string Describe() => $"Player {Name}";
}
var player = new Player { Name = "Ada" };
lua.Globals.Set("player", IntoLuau.FromUserdata(player));
await lua.Load("player:save()").ExecuteAsync();
Player samePlayer = lua.Globals.GetUserdata<Player>("player");
using LuauUserdata playerRef = lua.Globals.GetLuauUserdata("player");
_ = playerRef.TryGetManaged(out Player? resolvedPlayer, out string? error);Prefer [LuauUserdata] for regular script-facing properties, methods and operators. Implement ILuauUserdata<T> by hand only when you need behavior the generator cannot express, such as member names that are only known at run time. See Userdata for metamethods, the static side of a type, and registration by hand.
CreateFunction(...) also supports managed userdata parameters and returns for generated [LuauUserdata] types and manual ILuauUserdata<TSelf> implementations.
Prefer [LuauModule] for regular fixed host APIs:
[LuauModule("game")]
public static partial class GameModule
{
[LuauMember("answer")]
public static int Answer => 42;
[LuauMember("add")]
public static int Add(int left, int right) => left + right;
[LuauMember("wait")]
public static Task Wait(double seconds, CancellationToken cancellationToken) =>
Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken);
}
lua.RegisterModule(GameModule.ModuleName, GameModule.OnLoad);Use manual RegisterModule(...) when you need dynamic table construction or unsupported callback shapes:
lua.RegisterModule("game", static (state, in LuauTable module) =>
{
module.Set("answer", 42);
using LuauFunction add = state.CreateFunction((int a, int b) => a + b);
module.Set("add", add);
});Host modules are loaded from Luau with require("game"). Generated and manual modules share the same require context as file-backed script modules.
LuauTable,LuauFunction,LuauCoroutine,LuauString,LuauBuffer,LuauUserdata, and reference-backedLuauValueare owned references and should be disposed.LuauTableView,LuauFunctionView,LuauCoroutineView,LuauStringView,LuauBufferView,LuauUserdataView, andLuauArgsare borrowed callback-scoped values. Read them before a callback awaits.- Reference-backed values belong to one
LuauState; cross-state usage is invalid.
Compared with:
- NuLua (Unified Lua5.x/LuaJIT/Luau bindings for .NET and Unity)
- NLua (dynamic bridge between Lua world and the .NET)
- Lua-CSharp (High performance Lua interpreter implemented in C# for .NET and Unity)
The benchmarks cover synchronous workloads and async invocation, including managed callbacks that complete immediately or await Task.Yield(). Darp.Luau and Lua-CSharp run all async scenarios; NuLua runs async invocation and completed callbacks. NLua is not measured in the async scenarios. See how to run the benchmarks and the NuLua yielding limitation.
Latest run on a GitHub-hosted runner; the chart names the versions that were measured. See the full results and the benchmark code.
Load(...).Execute(...)is the script execution API today. UseLoadFile(path)to load an entry script from disk.CreateFunction(...)is generator-backed and has no runtime fallback.LuauStateis not thread-safe. Continuations of async callbacks run one at a time on the thread pool, or on a host dispatcher passed to the constructor. Use the synchronous API only when no async call is outstanding or from inside a callback.- Async managed callbacks need an async host call (
ExecuteAsync,InvokeAsync,ResumeAsync). - Cancelling the token of an async host call (
ExecuteAsync,InvokeAsync,ResumeAsync) stops the script. The synchronous calls take no token.
Darp.Luau is pre-1.0 and breaking changes are still expected. The aim before 1.0 is breadth: basic support for every larger Luau concept before completing any single one.
Planned before 1.0:
- Structured errors with script location, traceback, and the original managed exception
- Luau type definitions generated for the host API
- More than four arguments and return values, including variadics
- Script modules from sources other than disk
- Consistent naming, such as
LuauException
Later:
- Metatables on plain tables from the host
- Vectors as host values
- Memory limits, garbage-collector control, and isolation for untrusted scripts
- Compiler options, precompiled bytecode, and native code generation
- Converting C# objects to and from tables
- A thread-safe
LuauState. The host is responsible for using a state from one thread at a time. - Sharing Luau-backed references between states.
- Target frameworks older than
net10.0. Going forward, only .NET versions that are in service are supported. - Game-engine runtimes such as Unity or Godot.
- Debugger and profiler hooks.
- Type-checking scripts from the host. Use the generated type definitions with editor tooling instead.