Skip to content

Writing a Lua module

For authoring Lua content with the existing modules, start with Writing Lua scripts. This guide extends the host bindings in C#.

A Lua module is a plain C# class marked with [ScriptModule("name", "help text")]. The engine binds every registered module once, at startup, on the game loop thread, and publishes it into the running LuaState as a read-only global table named after the attribute. On that table:

  • every public, non-generic instance method marked [ScriptFunction] becomes a callable field;
  • every [ScriptConstant]-marked static member becomes a read-only value field;
  • every enum a bound signature or constant mentions — or that is registered explicitly — is published as its own read-only global table mapping member names to numbers.

A module is registered from a plugin’s Register(Container container) method, or, for a module the host itself owns, directly in the host (src/Moongate.Server/Bootstrap/Internal/ServerRoleRegistration.cs registers the built-in log module the same way, with .AddScriptModule<LogModule>()). Registration only records the type; nothing is reflected and no Lua table exists until the script engine starts and binds it.

Scripts run inside a sandbox: io, os and debug are never opened, dofile, loadfile and rawset are removed, and print is redirected to the server log instead of the console. string.rep also refuses to build a result past a configured cap. See the package README’s “Sandbox” section for the full list.

Start with the console plugin from Writing a plugin, built against the same package versions as your server. This first module has no constructor dependencies, enums or constants. Create MyShard.Plugin/Modules/HelloModule.cs:

using Moongate.Scripting.Attributes.Scripts;
namespace MyShard.Plugin.Modules;
[ScriptModule("shard_hello", "Says hello.")]
public sealed class HelloModule
{
[ScriptFunction(helpText: "Returns a greeting for name.")]
public string Hello(string name)
{
return $"Hello, {name}!";
}
}

Add these imports to MyShardPlugin.cs, and the registration line inside its existing Register(Container container) method, after the hello command registration:

using Moongate.Scripting.Extensions.Scripts;
using MyShard.Plugin.Modules;
container.AddScriptModule<HelloModule>();

Rebuild and redeploy the bundle using the plugin guide. In a game or standalone root, append this line to <root>/scripts/init.lua (keep its existing content):

print(shard_hello.hello("Moongate"))

Restart the server. The engine binds shard_hello before running init.lua; the server log prints Hello, Moongate!. You can also find shard_hello.hello in <root>/scripts/definitions.lua. The Lua call uses a dot, with no implicit self. A login process does not start the world script engine.

The complete sample extends this with a shared counter, an enum and a constant. GreetingCounter is defined in GreetingCounter.cs; its plugin registers it with container.RegisterInstance(new GreetingCounter()) before adding GreeterModule. Use the complete sample bundle when trying the following code; it is separate from the dependency-free shard_hello module above.

The sample plugin’s Tone enum, from samples/Moongate.Sample.Plugin/Types/Tone.cs:

namespace Moongate.Sample.Plugin.Types;
/// <summary>How warmly the greeter speaks. Published to Lua as the <c>Tone</c> table.</summary>
public enum Tone
{
/// <summary>"Hello, name!"</summary>
Plain = 0,
/// <summary>"Hello there, name!"</summary>
Warm = 1,
/// <summary>"Good day, name."</summary>
Formal = 2
}

The module that takes it, from samples/Moongate.Sample.Plugin/Modules/GreeterModule.cs:

using Moongate.Sample.Plugin.Internal;
using Moongate.Sample.Plugin.Types;
using Moongate.Scripting.Attributes.Scripts;
namespace Moongate.Sample.Plugin.Modules;
/// <summary>The <c>greeter</c> Lua module: one function, one constant, and the <see cref="Tone"/> enum it takes.</summary>
[ScriptModule("greeter", "Greets whoever asks.")]
public sealed class GreeterModule
{
private readonly GreetingCounter _counter;
/// <summary>The greeting used when no tone is given; visible to scripts as <c>greeter.DEFAULT_GREETING</c>.</summary>
[ScriptConstant("DEFAULT_GREETING", "The plain greeting word.")]
public static readonly string DefaultGreeting = "Hello";
/// <summary>Initializes a new instance of the <see cref="GreeterModule"/> class.</summary>
/// <param name="counter">Shared with the metric provider, which reports how often <see cref="Hello"/> ran.</param>
public GreeterModule(GreetingCounter counter)
{
_counter = counter;
}
/// <summary>Builds a greeting for <paramref name="name"/> in the given <paramref name="tone"/>; scripts call it as <c>greeter.hello(name, tone)</c>.</summary>
[ScriptFunction(helpText: "Returns a greeting for name, in the given tone.")]
public string Hello(string name, Tone tone = Tone.Plain)
{
_counter.Increment();
return tone switch
{
Tone.Warm => $"Hello there, {name}!",
Tone.Formal => $"Good day, {name}.",
_ => $"{DefaultGreeting}, {name}!"
};
}
}

A script that uses it:

greeting = greeter.hello('Moongate', Tone.Warm)
function report() return greeting, greeter.DEFAULT_GREETING end

At startup the engine writes definitions.lua for editor completion. The full file begins with a ---@meta header and the built-in wait function, and from a running server also declares the built-in engine, timer, events and log modules, the Ultima plugin’s dice, cliloc, localization, npc, item, mobile, world, target, hue_picker, prompt, skill, combat, gump, bank, stable, mount, pet, vendor, trainer, effect, moongates, locations, jail, schedule, help, book, commands, harvest, craft, carve, board, spell, npcguild, guild and party, and anything else registered. The excerpt below is what the sample module and its enum produce:

---@enum Tone
Tone = {
Plain = 0,
Warm = 1,
Formal = 2,
}
---Greets whoever asks.
---@class greeter
---@field DEFAULT_GREETING string
greeter = {}
greeter.DEFAULT_GREETING = "Hello"
---Returns a greeting for name, in the given tone.
---@param name string
---@param tone? Tone|string
---@return string
function greeter.hello(name, tone) end

Note the parameter annotation: tone? Tone|string — the ? because tone has a C# default, Tone|string because the converter accepts either the enum’s number or a member’s name on input. A function returning an enum would be annotated with the bare enum name — @return Tone, not Tone|string — because the engine always hands the caller the member’s number, never its name.

[ScriptFunction] is valid only on a public, non-generic instance method. A static, non-public or generic method carrying it is a binding error, not a silently missing function: "{Module}.{Method}: a [ScriptFunction] must be a public, non-generic instance method."

The Lua name is the attribute’s name argument when given (it must be a lower-case Lua identifier; the attribute rejects anything else), or the method name converted to snake_case otherwise: each run of uppercase letters starts a new lowercase, underscore-separated word, so NextColour becomes next_colour.

Parameters and returns are converted strictly, with no coercion between kinds, and range- and integer-checked for int/long:

C# type Accepts from Lua
bool boolean
int, long an integral number in range
double, float any number
string string
an enum the underlying number (must be a defined member) or the member’s name (case-sensitive)
a nullable value type (int?, Tone?, …) the above, or Lua nil (becomes C# null)
LuaValue anything, unconverted, including nil
LuaTable table
object number (as double), string, boolean, table, or nil (becomes C# null), unwrapped; anything else stays a LuaValue
params array every remaining argument, converted one by one to the array’s element type

A wrong argument raises a Lua error of the form bad argument #{n} to '{module}.{function}' ({reason}), where the reason names the expected type and what was received, for example Int32 expected, got string, nil cannot be converted to Int32, {number} is out of range for Int32, or '{name}' is not a member of Tone. LuaValue, object and correctly typed params elements never fail.

Lua takes an enum member by name case-sensitively; GreetCommand’s own tone parsing on the console side is deliberately more lenient (see Writing a plugin: Console commands). The two are independent choices for different callers.

A parameter is optional when the C# method gives it a default value, exactly like Hello(string name, Tone tone = Tone.Plain) above: if the script omits the argument the default is used; if the parameter is required and the script omits it, the error is "bad argument #{n} to '{module}.{function}' ({parameter} is required)".

Return types are converted with the same table. void returns nothing to Lua. Tuples are not supported: a method returning a System.ValueTuple fails to bind at all with the same "return type {Type} cannot be bound" error described under Registering, thrown before any script runs. A bound function always hands back at most one value; a script wanting multiple results defines its own Lua function around the call, as report() does in the sample above.

An exception thrown by the method itself becomes a Lua error a script can pcall: the binder unwraps the TargetInvocationException and raises "'{module}.{function}' failed: {innerException.Message}".

Every bound function runs on the game loop thread — the binder checks a thread guard before each call — so it must not block, sleep, or wait on anything; see Common mistakes.

[ScriptConstant] only has an effect on a public static readonly field or a public static get-only property. Anything else, such as an instance member, a non-public one, or a property with a setter, is a binding error: "{Module}.{Member}: a [ScriptConstant] must be a public static readonly field or a public static get-only property."

Supported constant types are int, long, double, float, bool, string, and enums — narrower than what a function parameter or return accepts: LuaTable, LuaValue and object are explicitly excluded even though the converter otherwise supports them. The mismatch error names the member and the type: "{Module}.{Member}: constants of type {Type} are not supported; use int, long, double, bool, string or an enum." A getter that throws is also a binding error, naming the member and keeping the original exception as the cause: "{Module}.{Member}: the constant's getter threw {ExceptionType}: {Message}".

Every module table is a read-only proxy: reads pass through to the real table, but any assignment — to an existing key or a new one — raises "'{name}' is read-only", and setmetatable on it raises too, since its __metatable is locked. rawset, the one call that could otherwise bypass this, is removed from the sandbox entirely.

An enum is published as a read-only global table named after the type, keyed by member name with the member’s numeric value (Tone.Plain == 0, and so on). This happens automatically the first time a bound function’s parameter or return type, or a constant’s type, is that enum — or explicitly via container.RegisterScriptEnum<TEnum>(), which publishes it even if nothing else mentions it (SamplePlugin.cs does this for Tone, even though GreeterModule.Hello already causes automatic discovery). A script may pass either the member’s underlying number or its exact-case name to a parameter of that enum type; the engine always returns the number. definitions.lua renders each one as ---@enum Name followed by a table literal, as shown above for Tone.

The published table’s name is exactly the C# enum’s own type name, with no qualifier: a script writes Tone.Warm.

The sample plugin registers the module and its enum with these two lines from samples/Moongate.Sample.Plugin/SamplePlugin.cs:

container.AddScriptModule<GreeterModule>();
container.RegisterScriptEnum<Tone>();

AddScriptModule<TModule>() does two things: it registers TModule in the container as a Reuse.Singleton, and it records the type in the IScriptModuleRegistry the engine reads at startup. Neither action touches Lua — the engine resolves each registered type from the container and binds it only when it starts. Because the module is a container singleton, any other class that takes it as a constructor dependency resolves the very same instance: the sample’s GreetCommand(GreeterModule greeter) is handed the identical GreeterModule the script engine binds, so the console command and Lua scripts share its state. A module’s own constructor dependencies — here, GreetingCounter — must already be registered in the container by the time the engine starts, since resolution happens then, not at AddScriptModule time; the sample registers it first with container.RegisterInstance(new GreetingCounter()).

RegisterScriptEnum<TEnum>() only adds the registry entry; there is no DI singleton to create, since an enum is reflected, not resolved.

Binding runs once, at startup, and any failure is reported through the exceptions already listed above:

  • a duplicate Lua name between two functions, or between a function and a constant, in the same module: "{Module}.{Member}: Lua name '{name}' is already used in module '{module}'."
  • a [ScriptFunction] on a static, non-public or generic method: "{Module}.{Method}: a [ScriptFunction] must be a public, non-generic instance method."
  • a function parameter or return type the converter cannot bind: "{Module}.{Method}: parameter '{parameter}' of type {Type} cannot be bound." / "{Module}.{Method}: return type {Type} cannot be bound."
  • a [ScriptConstant] that is not a public static readonly field or a public static get-only property, or is of an unsupported type (see Constants and enums for the exact messages).

See Writing a plugin: registering Lua modules for where Register fits in the plugin lifecycle.

The site’s Lua API reference is generated from the same attributes as definitions.lua. A module the shipped server registers, in AddUltimaScriptModules or in Moongate.Scripting, gets a page there with nothing to write by hand: the page shows the [ScriptModule] help text, and each function shows its Lua signature, its helpText and its parameters with their defaults. So both texts are required for those modules: the site build fails on a module without help text or a function without helpText. A module registered by a plugin of your own is not listed on the site; its definitions.lua entries are still written.

To show an example under a function of a shipped module, add a ## <function> section to website/lua/examples/<module>.md; see Writing documentation.

A callback is taken as a LuaValue, which the editor and the site would show as any. Mark it [ScriptParameterType("function")] LuaValue callback, as timer.after does, so both show function.

LuaModuleDescriber.Describe(typeof(GreeterModule)) gives the same description without building the module, and LuaFunctionDescriber.Describe(function) gives a function’s parameters and return in Lua terms, if you want to generate documentation for your own modules.

The minimal path needs no server: open the libraries a module needs, bind it with LuaModuleBinder and NoThreadGuard.Instance (a guard that never rejects a caller, for hosts without a game loop), then run a chunk directly. The package README’s example shows exactly that with a one-function greeter module.

For the full range of conversions, error cases, and edge cases (params, enum-by-number-or-name, out-of-range integers, the read-only proxy, duplicate names), see the binder tests under tests/Moongate.Tests/Scripting/Binding/, which bind the fixture modules in tests/Moongate.Tests/TestSupport/Scripting/ the same way.

To test a module through the full stack (plugin loading, DI, the game loop, and the generated definitions.lua), tests/Moongate.Tests/Integration/Plugins/SamplePluginTests.cs boots a real MoongateServerBootstrap against a temporary plugin and scripts directory, then calls into Lua on the loop thread, runs the console command that shares the module, and reads the metric it recorded.

  • Returning Task or ValueTask. Module methods run synchronously inside the bound Lua call; an async return type is not a supported type, so it fails to bind at startup with "{Module}.{Method}: return type {Type} cannot be bound."
  • Taking a class or interface parameter. Only the types in the Functions table convert; anything else — a custom class, an interface, a collection — fails to bind with "{Module}.{Method}: parameter '{parameter}' of type {Type} cannot be bound."
  • Expecting __tostring or metatables on the module table. The binder locks the table’s metatable (__metatable = "locked"); setmetatable(mymodule, {}) raises a Lua error, and no __tostring hook is installed.
  • Expecting the table to be writable. Every assignment — even to a name that already exists — raises "'{name}' is read-only", and rawset, the usual way around a metatable, is removed from the sandbox.
  • Calling into the engine from another thread. Every bound function checks a thread guard first; calling one off the game loop thread raises "{member} must be called on the game loop thread. Post a work item to the loop instead."

Made with ❤️ by Tom

Buy me a coffee