lloyAlloy on GitHub
Project files and mounts
Docs

Project files and mounts

Updated

Alloy needs to know where each folder lands in the DataModel. Two things can say so, and a project writes one of them.

The project file. A root with a Rojo or Argon project file needs nothing in alloy.toml: Alloy reads the file and takes its tree as written. It reads default.project.json, or the file [project] file names, or the one *.project.json at the root.

{
  "name": "game",
  "tree": {
    "$className": "DataModel",
    "ReplicatedStorage": {
      "$className": "ReplicatedStorage",
      "Shared": { "$path": "src/shared" },
      "Packages": { "$path": "Packages" },
      "Alloy": { "$path": "build/alloy.luau" }
    },
    "ServerScriptService": {
      "$className": "ServerScriptService",
      "Server": { "$path": "src/server" }
    }
  }
}

Every $path in the tree is a folder on disk with a place in the DataModel, at any depth. $className, $properties, and $ignoreUnknownInstances are directives, so they never become instances. .server. and .client. in a file name pick the script class, init names its directory, and a container between a service and a leaf, StarterPlayerScripts, keeps its own class.

Alloy derives four things from that tree, and writes none of them back into your file:

.alloy/build.project.json the same tree over the output sourcemap.json the instance tree with the source paths the @alias rewrite instance paths in the ship artifact the runtime's place where build/alloy.luau lands

The build project points every $path under [build] in at its output under [build] out, and leaves any other path as it is; rojo serve and rojo build read that one. The sourcemap sits at the root under the name Rojo writes and luau-lsp reads, so rojo sourcemap and the language server both find this one; [project] sourcemap = true, the default, writes it on every build, over a file another tool wrote there, and false writes none. Roblox reads no .luaurc, so require("@shared/economy") in the ship artifact becomes require("@game/ReplicatedStorage/Shared/economy"). A folder that Roblox copies for each player or character, StarterPlayerScripts, StarterCharacterScripts, StarterGui or StarterPack, is different: a script there runs from its copy. So an alias require from the same mount writes the relative path to the copy, and one from another mount is an error.

Without a [mount] table, default.project.json is yours: Alloy never writes it. A [mount] table that names places is the tree instead, and alloy build writes default.project.json from it, over the file at the root; [project] source_of_truth = false keeps your file. The runtime lands where the tree already mounts build/alloy.luau, else as alloy inside the node that mounts [build] out, or [build] in, which the build project points at [build] out, else at @game/ReplicatedStorage/Alloy; [project] runtime names it outright. A folder that carries the runtime gets no second node for it.

The aliases. They come from .config.luau or .luaurc, which is where Luau reads them, and where the editor and alloy flux already read them. An alias names a folder on disk; the tree says where that folder lands; the ship artifact writes the instance path.

{ "languageMode": "strict", "aliases": { "shared": "src/shared", "pkg": "Packages" } }

A data file resolves the same way: import config from "@shared/data/config.json" reads src/shared/data/config.json and requires the module the build writes beside it. alloy init writes the @alloy alias once; nothing else writes to these files.

The reserved names. Two aliases belong to the compiler, and a project that declares one never reaches the folder it named. @game is the Roblox services, import Players from "@game/Players", and the DataModel path past a service; @alloy is the runtime. A [mount] table that declares either name is an error, and so is a .luaurc or .config.luau that declares game. The report names the file the alias came from, in alloy check and in the editor. alloy init writes @alloy into the Luau configuration itself, so that one file may declare it.

Watch mode. alloy build --watch polls the sources, alloy.toml, the project file, every folder the tree mounts, and each ingot's manifest and binary, so a new file anywhere in the tree writes a new sourcemap and a new build project, and a rebuilt ingot runs again. A change to alloy.toml moves what the watch polls.

The mount table. A tool that reads a Rojo or Argon project file needs no table. A sync tool with its own format has no such file, so alloy.toml describes the tree instead:

alloy.tomlalloy.toml
[project]
name = "game"

[mount]
# alias = [path, mount]
server = ["src/server", "@game/ServerScriptService/Server"]
client = ["src/client", "@game/StarterPlayer/StarterPlayerScripts/Client"]
shared = ["src/shared", "@game/ReplicatedStorage/Shared"]
pkg = ["Packages", "@game/ReplicatedStorage/Packages"]

The table is the tree when the project writes one, over any project file at the root. Two keys of [project] say how far it reaches, and both are on:

alloy.tomlalloy.toml
[project]
# write default.project.json and .alloy/build.project.json from [mount]
source_of_truth = true
# serve the mount names as aliases, beside the Luau config ones
mount_aliases = true

source_of_truth = false writes neither project file and derives no build project: the sync tool of the project owns the tree, and the table is left to rewrite an @alias require into an instance path in the ship artifact. mount_aliases = false leaves the aliases to .config.luau or .luaurc alone, so a mount name completes and resolves nowhere.

With both on, alloy build writes default.project.json over the sources, so rojo serve has a file to read, and @shared/x completes in the editor whether the Luau configuration names it or not. A name in the Luau configuration always wins over a mount of that name.

An alias with no place. A folder a mount already carries can take a name of its own. Write the path alone, with no DataModel location:

alloy.tomlalloy.toml
[mount]
shared = ["src/shared", "@game/ReplicatedStorage/Shared"]
types = "src/shared/types"

@types/ids then resolves in the compiler and in the editor, and the ship artifact writes @game/ReplicatedStorage/Shared/types/ids, the path the shared mount gives the folder. The entry adds no instance, and alloy build writes it into no project file.

The folder has to sit under a mount, or under a node of the project file when that is the tree. An entry whose folder sits under neither is an error on that line: the name resolves at build time and finds nothing at run time. [project] mount_aliases = false turns the mount names off, and this check with them. A table of alias-only entries alone is no tree, so a project file at the root still describes one.

The path names a folder, since @types/x reads a file in it. An entry that names a file, or a path that is not there, reports the same way.

A name in .config.luau or .luaurc wins over a [mount] name, alias-only ones included, and .config.luau wins over .luaurc. Inside [mount] one name is written once, since TOML rejects the second, so an alias-only entry never collides with a mount there.

The side of a file. A .client or .server name says which half of a remote the file sees. Every other name is shared, and a shared file sees both halves.

Neither. A root with no project file and no table still builds. The output tree mirrors the source tree, emitted code requires the runtime by a relative path, and @alias requires stay as they are: no instance paths, no sourcemap, no project files.