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:
- 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). - 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.
- Resolve the asset's primary file. Every asset type names a primary code
file — a
.module's primary is itsinit.luau, and so on.requireloads that file. This is why you can require a module by its folder and never nameinit.luauyourself.
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.