Module (asset type)
When to use one
- You have logic you want to share across multiple components, services, scenes, or tools.
- You have a registry, lookup table, or shared cache that callers all read from / write to.
- You want a stable API surface (
M.fn) other code can depend on.
If the logic is bound to one entity, use a .component. If you want a
named UI of fields rather than functions, use a .preset.
Where it lives
- Source:
/zero/source/.../<name>.module/ - Identity: the source path with
/written as.and each segment's type suffix dropped —/zero/source/demo/beats.moduleisdemo.beats, and/zero/source/kit.package/beats.moduleiskit.beats, the container's name kept and its.packagesuffix gone.asset.createreturns this string,asset.resolve(...).identityreads it back, and therequireforms below write it as<identity>. - Folder shape:
init.luau(orinit.lua) — module body. Required. Returns a table (conventionally namedM).README.md— instance-level documentation. Required..metadata— agent-editable tags + free-form fields. Required.
How to create one
asset.create("module", "<name>")
-- Creates: /zero/source/<name>.module/
-- init.luau (canonical `local M = {} … return M`)
-- README.md (instance README template)
-- `folder` places it in a subfolder of /source, and that subfolder
-- leads the identity: `{ folder = "lib" }` produces `lib.<name>`.
-- `into` authors it inside a resolved container (a package, a toolbox),
-- whose own identity leads the same way:
asset.create("module", "<name>", { folder = "lib" })
asset.create("module", "<name>", { into = asset.resolve("myPkg.package") })
How it operates
-
Registration. Writing
init.luauinto a.module/folder indexes the asset and registers the module path with the resolver. -
Loading. The first
require("<identity>")runs the file's body and caches the returned table. Subsequent requires from anywhere return the same table — this is the shared-state lever for "module-local" registries. The body runs in an environment of the module's own: a name it declares withoutlocalis readable by the module's own functions and by no other script, so an export reaches its callers through the returned table and nothing else. Requiring a component's folder runs its chunk the same way, once, and itsupdate,startandpublicstay in that environment; a component runs on an entity throughentity(id).component.add(name). -
Hot reload. Editing the file re-runs the module body and folds what it returns into the table already loaded, so that table keeps its identity: a caller that captured it with
requirereads the new source's functions and values through the reference it already holds, and a laterrequirereturns that same table. A key the new source drops goes away; a key something set at runtime stays. The body's ownlocals start again from their initial values, so state that must outlive an edit lives inmodules.state()(guides { path: "core/scripting-and-tasks" }). -
Identity resolution. A module is reached by its
<identity>, and where the caller lives decides which spelling of it resolves.For a module in a world, from a caller in that same world — another module, a component, a scene entrypoint, or an
executechunk:require("<identity>")— the identity on its own, resolved against the caller's own root. It carries every folder segment, so a module created with{ folder = "demo" }isrequire("demo.beats").require("@root::<identity>")— the same root-relative resolution, written out.require(".sibling")— a module in the same folder, by its bare name;..namesteps up one folder,...nametwo.
Logs, stack traces and hot-reload notices name that same module
@local.source.<identity>— its key under the world source root. The<identity>tail of that key is the form above, so a trace reading@local.source.demo.beatsisrequire("demo.beats").For a module in a library — content under
/zero/source/libs/@<lib>/—@<lib>::addresses that library's root from anywhere:require("@builtin::modules.transform"). Mounting a world as a library moves its files under such a root and flips the caller root to@<lib>::, which is what keeps the root-relative forms above resolving across the move.
Discovery
asset.list("module")— every registered module.asset.inspect("<name>")— public functions, source path, this type README.ls /zero/source/<name>.modulelists the files the folder holds.
Authoring conventions
- Return a single
Mtable frominit.luau. Top-level statements with side effects run on first require — useful for one-time initialization, dangerous if they touch the engine before it's ready. - Write exported functions as
typed function, notfunction: the argument types are enforced at the call and the checker knows the signature, so a caller's mistake is reported where they made it. - Annotate exported functions with
--!desc/--!arg/--!return/--!exampleso the LSP,tools.list, andasset.inspectsurface them. A function with no--!descis reachable only by someone already reading this file. - Declare a VALUE with
Field.<kind>(default, mode, description), and a table whose members are reached through a metatable with--!members <TypeName>— the latter gives the surface its accepted member set, its type, and its documentation from one table. Seeguides { path: "core/authoring" }for all four rules andmodules/api/engine/entity.module/members.modulefor a worked member table. - Keep module state in module-local upvalues or in
modules.state(). A name the body declares withoutlocallives in the module's environment, which every run of the module shares: a function the previous run left in place reads what the reload assigned. Module-locals start again from their initial values with each hot reload; the tablemodules.state()returns is the same table on both sides of every reload. - Prefer focused modules over kitchen-sink modules. If two halves of a module have no shared state, split them.
Common pitfalls
- Cyclic requires. A
requirechain that loops will return the partial table (the half built before the cycle was detected). Design for one-way dependencies. - Engine-time side effects. Don't
entity.spawnat module top-level — the engine may not be ready. Expose anM.initand call it from a scene entrypoint. - Hot-reload + module-local cache. A module that caches expensive computation in upvalues loses that cache on reload — fine for dev, but be aware in performance work.
init.luavsinit.luau. Either works,.luauis canonical.- Writing one while play runs. A
/sourcewrite made while play is running lands on the play shadow, and a guarded play-exit discards it unless it is kept. One.modulewrite shadowsinit.luau, theREADME.mdand the.metadatatogether.vfs.write(path, bytes, { durable = true })skips the shadow, andvfs.promotePlayShadowkeeps what is already on it — thecore/vfsguide has the model and what each route costs.
Related types
.component— for entity-bound state + lifecycle hooks..service— generates content you don't have yet (a mesh, sound, texture, …) via a metered provider..tool— for a single agent-callable function with a YAML schema..package— to group several modules + components + scenes into one shippable folder.
In the Inspector
Exports: what the module exports (name, kind, signature), parsed from its source without running it, with Check (the LSP's diagnostics for its entry file) and Open.