Writing Engineering & games

How a C# object becomes a Luau value

A Polytoria script writes part.Position = Vector3.new(0, 5, 0) and it works. But part is a C# object living in a .NET heap, and the script is Luau, which knows about eight types and none of them are Part.

Something in the middle has to make one look like the other. That something is LuauProvider.cs, and it is the most interesting file in the engine.

The problem, stated properly

Lua’s type system is small on purpose: nil, boolean, number, string, table, function, userdata, thread. There is no class, no field, no method in the sense a C# developer means. A table with a function in it is the closest approximation, and “method call” is sugar for passing the table as the first argument.

C# is the opposite: static types, compile-time members, and a garbage collector that has no idea a scripting VM is also holding references.

Bridging them means answering four questions:

  • How does a script hold a reference to a C# object?
  • How does reading a field on it dispatch into C#?
  • What happens to values crossing in each direction?
  • Who keeps the object alive, and for how long?

Userdata, not tables

The naive approach is to copy the object into a Lua table. It falls apart immediately: the copy diverges from the original the moment either side mutates, and a script could overwrite methods.

The real answer is userdata — Lua’s type for “an opaque block the host owns.” Lua will store it, pass it, and garbage-collect it, but it cannot look inside. Polytoria pushes objects as userdata and attaches a metatable that intercepts every access.

Metatables are the mechanism the whole bridge rests on. When Lua evaluates part.Position and part is not a table with that key, it consults the metatable’s __index. If __index is a function, Lua calls it with the object and the key — and now you are in C#, holding both, free to look up a property by name and return whatever you like. Assignment goes through __newindex the same way.

So the script is not reading a field. It is calling a function that pretends to be a field. Every property access on every instance is a dispatch through that hook.

Identity has to be preserved

Here is a subtle requirement that is easy to get wrong. If a script does this:

local a = workspace.Boat
local b = workspace.Boat
print(a == b)

it had better print true. But each access returns a fresh userdata wrapper unless something prevents it, and two distinct userdata values are not equal.

The engine keeps a cache, and the name tells you the design:

private const string WeakUserdataCache = "__UDCACHE";

A weak cache. Strong references would mean every object ever exposed to a script stays alive forever, because the cache itself would keep it reachable — a leak that grows with play time. Weak references let the collector reclaim an object once nothing else holds it, while guaranteeing that as long as it is alive, the same object maps to the same userdata.

This is the standard solution to a standard problem, and it is worth recognising because it appears in every language bridge: identity mapping with weak references, so equality survives without defeating collection.

Crossing the boundary is a type switch

Values going into Lua run through a dispatch chain that maps each .NET type onto something Lua understands:

state.PushNumber(intVal);
state.PushNumber(uintVal);
state.PushNumber(ulongVal);
state.PushNumber(longVal);
state.PushNumber(dblVal);
// ...
else if (value is decimal decimalVal)
{
    state.PushNumber((double)decimalVal);
}

Every numeric type collapses to PushNumber, because Lua has exactly one number type. That collapse is lossless for most values and lossy at the extremes: long holds integers up to about 9.2 × 1018, while a double represents integers exactly only up to 253, roughly 9 × 1015. Identifiers above that threshold silently lose their low bits.

This is not a Polytoria quirk. It is why JSON APIs are advised to send large IDs as strings, and why JavaScript eventually added BigInt. Any runtime whose only numeric type is a double inherits it.

Objects take a different path: anything implementing IScriptObject gets pushed as a cached userdata, and there is a proxy step for types that need a wrapper rather than direct exposure.

Practical note: if you are storing snowflake-style identifiers — Discord IDs, database primary keys, anything above 253 — keep them as strings end to end. The moment one becomes a Lua number it may already be wrong, and nothing will tell you.

Libraries are just objects pushed as globals

The same machinery loads the standard libraries. There is no special case:

public static readonly Dictionary<string, Type> LuaLibraries = new()
{
    { "json", typeof(LuaLibJSON) },
    { "guid", typeof(LuaLibGUID) },
};

Each entry is pushed with the same PushCSClass used for instances, then bound to a global name. json is not a privileged builtin; it is a C# class exposed the same way Part is. Methods are found through attributes on the C# members rather than a hand-written binding table, which is why adding a scriptable method is a one-line attribute rather than a registration ritual.

Why this design and not another

There are three common approaches to embedding a scripting language, and the trade is always the same.

Copy data across. Simple, fast to read, and wrong as soon as anything mutates. Fine for pure configuration, useless for a live scene graph.

Generate bindings ahead of time. Fastest at runtime, no reflection, but every new type needs generated glue and the build gets heavier.

Proxy through metatables. One mechanism covers every type, new members appear automatically, and the cost is an indirection on every access. This is what Polytoria does, and for a scene graph where scripts touch relatively few objects per frame it is the right trade — the dispatch is cheap next to rendering.

It also explains a performance rule that gets repeated without justification in every scripting community: cache your lookups. Writing local pos = part.Position once outside a loop rather than part.Position inside it is not micro-optimisation. Each access is a metatable hook, a managed-code transition, and a name lookup. The advice exists because of this file.

References

  1. R. Ierusalimschy, L. H. de Figueiredo, W. Celes. “The Implementation of Lua 5.0.” Journal of Universal Computer Science, 11(7), 2005. The canonical description of tables, metatables, and userdata.
  2. R. Ierusalimschy. Programming in Lua, 4th ed. Lua.org, 2016. Chapters on metatables and the C API cover the mechanism from the host side.
  3. Luau documentation, luau.org. Polytoria scripts in Luau, which adds gradual typing over Lua 5.1 semantics.
  4. R. Nystrom. Crafting Interpreters. Genever Benning, 2021. Useful for why dynamic dispatch through a hook is affordable.
  5. IEEE 754-2019, Standard for Floating-Point Arithmetic. The source of the 253 exact-integer limit.
  6. Polytoria engine source, github.com/Polytoria/polytoria-gamePolytoria/scripts/scripting/languages/luau/LuauProvider.cs.
← All writing Get in touch →