Log inGet started

Requiring modules

require is how one piece of Luau pulls in another. You hand it a string; it hands you back the table that module returned. The interesting part is the string — what you write decides how the engine…

  • by identity — a name in the asset index, which the engine resolves to an asset and then to that asset's code file;
  • by path — a literal location in the VFS hierarchy, walked like a directory tree.

You never choose between them with a flag. The engine reads the shape of the string and decides. Get the shape right and resolution is unambiguous; get it wrong and you get a loud "not found" rather than a surprise. This guide is about reading that shape.

The mechanism: shape → identity-or-path → asset → primary file

Every require runs the same pipeline:

  1. Classify by shape. Does the string carry a type suffix (.module, .tool, …) and/or a /? Then it's a path. Is it a suffix-free token, plain or dotted (json, modules.transform)? Then it's an identity. A leading . / .. / ./ / ../ marks it relative — anchored to the file doing the requiring — and the same suffix test then says whether that relative form is an identity (.helper) or a path (./helper.module).
  2. Resolve to an asset. An identity is looked up in the asset index — the string is a registry key, never parsed into a folder. A path is walked literally in the VFS from wherever it's anchored. Either way you land on one asset.
  3. Resolve the asset's primary file. Every asset type names a primary code file — a .module's primary is its init.luau, and so on. require loads that file. This is why you can require a module by its folder and never name init.luau yourself.

The throughline: identity and path are two front doors to the same back room. Both converge on an asset, then on that asset's primary file. The shape only decides which front door you walk through.

Hold onto one fact before the forms: an identity is never a path. @builtin::modules.transform does not "mean" the folder /zero/source/libs/@builtin/modules/transform — it means the index entry named modules.transform in the @builtin root. Where that asset's bytes physically live is incidental; the index knows, and you don't have to.

The forms

Cross-root identity — @scope::name

@scope:: is a root rekey. It says "resolve the rest of this string in library scope's root, not in mine." The library's own location is found through the index.

After the ::, a suffix-free remainder is an identity within that root:

local Transform = require("@builtin::modules.transform")   -- → table

The dotted token modules.transform is the index key inside @builtin. This is the everyday way to reach a built-in module from your own world: rekey to @builtin, then name the identity.

Cross-root path — @scope::a/b.module

Give the remainder a / and full type suffixes and it flips from identity to path — a literal location walked inside that library's root:

local mc = require("@builtin::demos/minecraft/minecraft.module")   -- → table

This addresses the file tree directly: demos/minecraft/minecraft.module is a real folder under @builtin, and naming the .module directory resolves to its primary file. Reach for this when you want a specific file by where it sits rather than by its indexed name.

Bare token — current-root identity

A suffix-free token with no @scope:: is an identity in the requiring file's own root:

-- inside a module that itself lives in the @builtin root:
local Transform = require("modules.transform")   -- → @builtin::modules.transform

This is the same identity lookup as @scope::, with the root left implicit as your root. The consequence catches people out: a module in your world source that writes require("modules.transform") looks for modules.transform in the world root, finds nothing, and fails —

-- inside a module under /zero/source (the world root):
require("modules.transform")
-- → error: module 'modules.transform' not found

— because the built-in lives in @builtin, not your world. The bare token is not shorthand for the VFS path /zero/source/modules/transform; it is an index lookup in your root. To cross into the engine's library, rekey: require("@builtin::modules.transform").

Relative — anchored to the requiring file

Two relative spellings, both anchored to the file doing the require, and the suffix test tells them apart.

Dot form — relative identity. Suffix-free, walks the module hierarchy (siblings and ancestors by logical name):

-- inside helper's sibling module:
local helper = require(".helper")        -- the sibling identity → table

Slash form — relative path. Full names with type suffixes, walks the directory tree:

local helper = require("./helper.module")     -- sibling folder → its primary
local up     = require("../target.module")    -- climb one directory, then resolve

./ is "this file's directory," ../ climbs one level — exactly like a shell. A .module directory resolves to its primary file; naming a specific file (../shared.tool/extra.luau) loads that file as-is, which is how you reach a submodule inside a composite asset.

Why ./helper is rejected

The suffix is the form-marker, and it is mandatory on the slash form:

require("./helper.module")   -- ✓ a path — resolves the sibling folder
require(".helper")           -- ✓ an identity — resolves the sibling by name
require("./helper")          -- ✗ error: module './helper' not found

./helper is a path (the ./ says so) that is missing its type suffix, so it names no real VFS entry; and it isn't a dotted identity either (the ./ disqualifies it). It is neither valid form, so it fails loudly rather than guessing. Write ./helper.module (path) or .helper (identity) — pick the door, name it fully.

One asset → one primary file

Whatever the form, resolution ends at an asset, and the asset type picks the code file to load. That is what makes "require the folder" work, and it is also why require is for code:

require("@builtin::demos/minecraft/minecraft.scene")
-- → error: module '...minecraft.scene' not found

A .scene's primary file is its scene data (JSON), not Luau. require converges on the scene asset, finds its primary isn't code, and stops with a clean "not found." It does not hand the JSON to the Luau compiler. Requiring a .material, or any data asset, fails the same clean way — require is the door for modules, and data assets are loaded through their own APIs.

Unresolved is loud, never silent

Every failure above is the same failure: a clear "module not found." This is deliberate and load-bearing.

require("@builtin::no.such.module")   -- → error: module '@builtin::no.such.module' not found
require("no_such_bare_token")         -- → error: module 'no_such_bare_token' not found

An identity with no index entry, or a path with no VFS entry, fails immediately. What it will never do is fall through — take an identity string, bend it into a folder path, and compile whatever file happens to sit there. A miss is a miss, reported as one, at the point you asked. When a require fails, read the form against the rules above: wrong root (use @scope::), wrong door (suffix on a slash path), or the asset genuinely isn't there.

The model to carry

Read the shape, and the rest follows: suffix or / ⇒ path (walked literally in the VFS), suffix-free token ⇒ identity (looked up in the index), leading dot ⇒ relative to the requiring file (and the suffix again says identity vs path). Both roads end at one asset and its primary code file. @scope:: rekeys which root an identity resolves in; a bare token uses your own root. And a require that can't find its target says so, plainly, instead of resolving something you didn't ask for.

  • documentation
  • guide