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. -
Hot reload. Editing the file reloads the module body and invalidates the require cache. Live callers that captured the table via
requirestill hold the old table until they re-require — design for this if you keep state in module-locals. -
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.cat /zero/source/<name>.module— same summary.
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, and the cat summary surface 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. Globals leak across reloads; module-locals reset cleanly with each hot 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.
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.