How to write a CoreAI Lua mod. Audience: the AI agent and human authors. Runtime VM: Lua-CSharp. Examples:
Assets/CoreAI.Demos/Mods/*.lua.
A mod is Lua source that the runtime runs ONCE through LoadMod. During that run it registers hooks;
afterwards the host drives those hooks. Persistent mods live across frames (and reloads). A one-off script
(execute_lua) runs once and is not persisted.
Start with a @coreai block so the mod is discoverable/manageable:
--[[@coreai
id: my_mod -- stable slug = identity (rename = new mod). [a-z0-9_]
name: My Mod -- display name (cosmetic)
version: 1.0.0 -- semver
active: false -- seeded enabled/disabled
capabilities: All -- e.g. All | All, Full
author: CoreAI
description: one line.
]]| Function | Meaning |
|---|---|
hooks_on(event, fn) |
fn(eventName, payload) runs when the game or another mod emits event. |
hooks_every(seconds, fn) |
repeating timer; seconds >= 0.05. "tick"/"update"/"frame" hooks map to a ~20 Hz timer. |
events_emit(name, payload) |
emit an event to the game + other mods. payload is a string. |
store_set(key, value) / store_get(key) |
per-mod persistent string key/value (survives frames + reloads). |
mod_id() |
this mod's id. |
report(msg) / print(msg) |
diagnostic line back to the host (muted by default; host enables per mod). |
| Function | Meaning |
|---|---|
mods_export(name, value) |
publish a value OR function under this mod's id. |
mods_get(modId, name) |
read another mod's exported plain data (nil for a function export). |
mods_call(modId, name, ...) |
call another mod's exported function on its own state; returns a copied result. |
mods_list_exports(modId) |
list export names (introspection — the AI discovers callable APIs). |
Hard rule (multiplayer-determinism seam): only PLAIN DATA (numbers/strings/bools/tables) crosses the mod
boundary. Functions, closures, and live references never leave a mod's own state — mods_call runs the
function in the provider and copies back the result. Nesting is capped (CrossModTableDepth = 4), cross-call
depth is capped (MaxCrossCallDepth = 8). See shared_stats_provider.lua + shared_stats_consumer.lua.
The mod-core + inter-mod API above is always present. Tiers gate the game bindings:
- Read — query only.
- WorldEdit —
coreai_world_*(spawn/change/destroy/scene/animation/sound) via the authoritative command channel. - LogicOverride —
logic_*formulas. - Full —
unity_*generic reflection (get/set fields, call methods on ANY component). Host/singleplayer-only, stripped on network clients. Opt-in ("Enable Full Lua Access"); NOT part ofAll. A binding absent from your tier simply doesn't exist in the sandbox (calling it errors).
coroutine.create/resume/yield/status are available. A coroutine lets a mod spread a sequence over time
without blocking — it yields, the host advances the frame, and you resume it next tick. Under Lua-CSharp this
is frame-pumped, so coroutine.yield works on WebGL too (a blocking wait would deadlock single-threaded WASM;
this needs the bundled VM at v0.5.6 or newer — older builds froze the player on the first yield).
See coroutine_countdown.lua. Do NOT busy-wait; yield and resume from a timer/handler.
C# owns per-frame hot loops (movement, camera, physics). Lua tweaks parameters and reacts to discrete
events — it does not run the hot loop. "Change a mechanic while playing" should DECLARE the change / emit a
command, not spin a transform every frame. Prefer routing world changes through events/commands (the
authoritative channel) over direct mutation — it stays deterministic and multiplayer-ready. See
day_night_cycle.lua.
- No
io/os/debug;load/loadstring/dofile/loadfileare removed. - An instruction budget (via Lua-CSharp
SetHook) cuts a runaway handler (while true do end) on ALL platforms incl. WebGL — a buggy mod cannot hang a frame. - Caps: per-handler steps/time, timer min interval
0.05 s, exports/mod, dispatch per tick (no events dropped; serviced on later ticks), quarantine after consecutive failures (mod stays loaded; reload resumes it).
Lua-CSharp targets Lua 5.2 semantics with double-only numbers — there is no integer/float
subtype and no native bitwise operators (&, |, ~, <<, >> postdate 5.2). Luau sources are
run through the downleveler (Luau → Lua 5.2) at ingestion. Stdlib coverage is partial — a missing
library function errors, so keep to common string/table/math calls.
Drop .lua files (each with an @coreai header) into a Resources/CoreAIMods/ folder. On the first
run BundledModSeeder (wired in CoreAiModsInstaller, runs before rehydrate) installs them into the
persistent store; active: true mods load immediately, active: false ones ship dormant (enable from the
Hub Mods tab). Two samples live in Assets/CoreAIMods/Runtime/Resources/CoreAIMods/
(sample_welcome.lua, sample_camera_pulse.lua).
Updates are version-driven and player-respectful:
- Bump the header
version:and re-ship → the seeder updates an unmodified copy, keeping the player's enabled/disabled choice. - If the player edited the mod, it is not overwritten — the entry is flagged
UpdateAvailablefor a manual update in the UI. - A same-or-older version, or a mod the player authored under the same id, is left untouched.
Hosts can add more IBundledModSources (StreamingAssets, Addressables, remote) alongside the Resources
one; see Docs/CoreAIMods/mod-system.md §3.
hello_world.lua— minimal: one event hook, one timer, a persistent counter.score_tracker.lua— events + store persistence.day_night_cycle.lua— a live mechanic via timer +events_emit(native/Lua boundary).coroutine_countdown.lua— a coroutine yielding across ticks (WebGL-safe).shared_stats_provider.lua/shared_stats_consumer.lua— inter-modmods_export/mods_get/mods_call.full_mode_cube.lua/first_person_controller.lua— Full-tierunity_*(reflection) examples.