Log inGet started

Module (asset type)

Updated 5 September 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.

  3. Hot reload. Editing the file reloads the module body and invalidates the require cache. Live callers that captured the table via require still hold the old table until they re-require — design for this if you keep state in module-locals.

  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.
  • cat /zero/source/<name>.module — same summary.

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 the cat summary 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. 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 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.
  • .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.
  • asset-type
  • reference