Log inGet started

Module (asset type)

Updated October 20, 2026

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.module is demo.beats, and /zero/source/kit.package/beats.module is kit.beats, the container's name kept and its .package suffix gone. asset.create returns this string, asset.resolve(...).identity reads it back, and the require forms below write it as <identity>.
  • Folder shape:
    • init.luau (or init.lua) — module body. Required. Returns a table (conventionally named M).
    • 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

  1. Registration. Writing init.luau into a .module/ folder indexes the asset and registers the module path with the resolver.

  2. 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 without local is 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 its update, start and public stay in that environment; a component runs on an entity through entity(id).component.add(name).

  3. 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 require reads the new source's functions and values through the reference it already holds, and a later require returns that same table. A key the new source drops goes away; a key something set at runtime stays. The body's own locals start again from their initial values, so state that must outlive an edit lives in modules.state() (guides { path: "core/scripting-and-tasks" }).

  4. 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 execute chunk:

    • 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" } is require("demo.beats").
    • require("@root::<identity>") — the same root-relative resolution, written out.
    • require(".sibling") — a module in the same folder, by its bare name; ..name steps up one folder, ...name two.

    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.beats is require("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>.module lists the files the folder holds.

Authoring conventions

  • Return a single M table from init.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, not function: 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 / --!example so the LSP, tools.list, and asset.inspect surface them. A function with no --!desc is 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. See guides { path: "core/authoring" } for all four rules and modules/api/engine/entity.module/members.module for a worked member table.
  • Keep module state in module-local upvalues or in modules.state(). A name the body declares without local lives 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 table modules.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 require chain 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.spawn at module top-level — the engine may not be ready. Expose an M.init and 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.lua vs init.luau. Either works, .luau is canonical.
  • Writing one while play runs. A /source write made while play is running lands on the play shadow, and a guarded play-exit discards it unless it is kept. One .module write shadows init.luau, the README.md and the .metadata together. vfs.write(path, bytes, { durable = true }) skips the shadow, and vfs.promotePlayShadow keeps what is already on it — the core/vfs guide has the model and what each route costs.
  • .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.

  • asset-type
  • reference