Overview
Vanta is a highly opinionated and minimalist module system framework for Roblox. This framework provides your game with a consistent structure without any boilerplate code, by automatically detecting, loading, and executing your modules at different points in the execution lifecycle, both on the Client and Server sides.
If you have ever required a script yourself, ordered your Init() calls to work properly, or been wondering which LocalScript caused an issue, then Vanta is made for you.
Why use this over require?
| Problem | Without a framework | With Vanta | |
|---|---|---|---|
| Module ordering | Manual, error-prone | Automatic, priority-driven | |
| Shared packages (Janitor, etc.) | Re-required everywhere | Loaded once, fetched by name | |
| Memory profiling | All scripts under one label | Each module tracked individually | |
| Inter-module dependencies | Hard-coded paths | Loader:Get('Path/To/Module') |
|
| Startup control | Scattered across LocalScripts | Single Bootstrap() call |
Lifecycle
Every module goes through three phases, in order:
Setup -> Init -> Start
All phases are optional, only implement what you need.
local MyModule =
{
Category = 'Services';
Priority = 5 --| higher = Start is called earlier
}
--| Phase 1: runs before any Init. No cross-module calls yet.
--| Use for self-contained setup (caching references, setting defaults).
function MyModule:Setup()
self.Player = Players.LocalPlayer
self.Character = self.Player.Character or self.Player.CharacterAdded:Wait()
end
--| Phase 2: all modules are required, so inter-module access is safe here.
--| Receives the Loader so you can fetch other modules and packages.
function MyModule:Init(Loader: Loader, Path: string)
self.InputService = Loader:Get('Services/InputService')
self.Janitor = Loader:GetPackage('Janitor')
end
--| Phase 3: everything is initialised. Spawn connections, start loops.
--| Modules with higher Priority run Start first.
function MyModule:Start()
self.InputService.KeyDown:Connect(function(Key)
--| ...
end)
end
return MyModule
Why three phases instead of one?
-
Setup is synchronous and isolated, safe to read game state, unsafe to talk to other modules (they may not be loaded yet).
-
Init is when the dependency graph is complete. Cross-module wiring goes here.
-
Start is fire-and-forget, each module’s Start runs in its own
task.spawn, so a yielding module never blocks another.
Performance
Memory categorisation
Every module’s require call is wrapped in debug.setmemorycategory, so each module gets its own label in the Developer Console’s memory view. Packages follow the same pattern under Package/<Name>.
--| Internally, for every module:
debug.setmemorycategory(Path) --| e.g. "Controllers/MovementController"
local Success, Result = pcall(require, ModuleScript)
debug.resetmemorycategory()
This means instead of seeing one giant LocalScript blob eating memory, you can pinpoint exactly which module is responsible.
Compiler flags
The core files ship with:
--!strict --| full type checking
--!native --| JIT-compiled via Luau Native Code Generation
--!optimize 2 --| maximum Luau optimization level
You can apply these to your own modules freely; the framework doesn’t interfere with them.
Start ordering
Start calls are sorted by descending Priority before dispatch, so high-priority systems (e.g. your input handler) are always up before lower-priority ones (e.g. UI), all without you having to think about it.
API Reference
Loader:Bootstrap()
Discovers all modules under Config.ModuleRoot, loads packages, then runs the full Setup -> Init -> Start lifecycle. Call this once from your main LocalScript or Script.
--| LocalScript (StarterPlayerScripts)
local Framework = require(ReplicatedStorage.Framework)
Framework:Bootstrap()
Loader:Get(Path: string): T
Returns the module registered at the given slash-separated path, relative to ModuleRoot. Errors with a list of all available paths if not found, so you always get a clear message instead of a silent nil.
function MyModule:Init(Loader: Loader)
self.AnimationController = Loader:Get('Controllers/AnimationController')
self.InputService = Loader:Get('Services/InputService')
end
Loader:GetPackage(Name: string): T
Returns a loaded package by its ModuleScript name. Packages live in ReplicatedStorage/Packages and are separated into Shared, Client, and Server folders.
function MyModule:Init(Loader: Loader)
self.Janitor = Loader:GetPackage('Janitor')
self.Network = Loader:GetPackage('Network')
end
Loader:IsRegistered(Path: string): boolean
Loader:IsPackageLoaded(Name: string): boolean
Guard checks, useful if you want optional dependencies without erroring.
if Loader:IsRegistered('Controllers/DebugController') then
self.Debug = Loader:Get('Controllers/DebugController')
end
Loader:List()
Prints every loaded module (with its Category and Priority) and every loaded package to the output. Great for debugging startup.
--- Client - Modules ---
Controllers/AnimationController Category=Controllers Priority=3
Controllers/MovementController Category=Controllers Priority=2
Services/InputService Category=Services Priority=0
--- Client - Packages ---
Janitor
Network
ShapecastHitbox
Real Usage Examples
AnimationController
A module that caches and plays animations, loaded by other controllers.
local AnimationController =
{
Category = 'Controllers';
Priority = 3 --| starts before MovementController (Priority 2)
}
function AnimationController:Setup()
self.Player = Players.LocalPlayer
self.Character = self.Player.Character or self.Player.CharacterAdded:Wait()
end
function AnimationController:Init(Loader: Loader)
self.Animations = ReplicatedStorage:WaitForChild('Animations')
self.Cache = {}
end
function AnimationController:Start()
self.Player.CharacterAdded:Connect(function(Character)
self.Character = Character
self:DestroyCache()
end)
end
--| Load (and cache) an animation track by path
function AnimationController:Load(AnimationPath: string): AnimationTrack?
if self.Cache[AnimationPath] then
return self.Cache[AnimationPath]
end
--| ... load from Animator, cache, return
end
return AnimationController
Another controller accesses it in its own Init:
function MovementController:Init(Loader: Loader)
self.AnimationController = Loader:Get('Controllers/AnimationController')
end
function MovementController:Start()
--| Preload all animations in the 'Movement' folder
self.AnimationController:BulkPreload('Movement')
end
Because AnimationController has a higher Priority (3 vs 2), its Start runs first, so by the time MovementController:Start fires, the animation controller is fully ready.
ToolController, Per-tool module system
ToolController itself is a framework module, but it also builds a mini module system for individual tools:
function ToolController:Start()
--| Each child ModuleScript under this script is a tool handler
for _, ModuleScript in script:QueryDescendants('ModuleScript') do
self.Tools[ModuleScript.Name] = require(ModuleScript)
end
end
function ToolController:OnToolEquipped(Tool: Tool)
local ToolModule = self.Tools[Tool.Name]
if not ToolModule then
warn(`Tool: '{Tool.Name}' has no controller`)
return
end
local ToolInstance = ToolModule.new(Tool, self)
--| Wire up Activated, Unequipped, etc.
end
A tool handler (e.g. Sword) receives the ToolController and uses it to reach other framework modules, without ever touching the Loader directly:
function Sword:OnActivated()
--| Access MovementController state to block swinging mid-slide
if self.ToolController.MovementController.IsSliding then
return
end
self.ToolController.AnimationController:Play('Sword/Swing')
--| ...
end
The Ignore Tag, Opting modules out of the loader
The framework uses CollectModules to recursively discover every ModuleScript under your module root. Sometimes a module shouldn’t be auto-required by the framework, for example, ToolController manages its own child scripts (one per weapon) and requires them itself at runtime. If the framework also required those, they’d be double-loaded and registered under paths you never intended.
Tag any ModuleScript with the instance tag Ignore and the framework will skip it entirely during collection.
Controllers/
ToolController <- required by the framework
Sword <- tagged 'Ignore', skipped by framework, required by ToolController
Bow <- tagged 'Ignore', skipped by framework, required by ToolController
You can apply the tag in Studio via the Tags property in the Properties panel, or via CollectionService in a setup script:
CollectionService:AddTag(script.Sword, 'Ignore')
The same rule applies to any helper or sub-module that lives inside a framework module’s script tree but isn’t a standalone system, tag it Ignore and manage it yourself.
Folder Structure
ReplicatedStorage/
Framework/ <- the framework itself
Packages/
Shared/ <- loaded by both Client and Server
Client/
Server/
Animations/ <- AnimationController reads from here
ServerStorage/
Modules/ <- Server module root
ReplicatedStorage/
Framework/
Modules/ <- Client module root
Controllers/
AnimationController
MovementController
ToolController
Services/
InputService
Advantages
-
Zero per-module boilerplate, no manual require chains, no ordering code.
-
Granular memory profiling, every module and package has its own label in the memory view.
-
Priority-based startup, critical systems (input, animation) start before dependents.
-
Clear error messages,
GetandGetPackageboth print every available path/name when something’s missing. -
Duplicate protection, packages silently skip duplicates rather than overwriting and causing subtle bugs.
-
Safe failure, every lifecycle phase is
pcall-wrapped; one broken module doesn’t take down the rest. -
Flat, predictable API, only five methods you’ll ever use:
Bootstrap,Get,GetPackage,IsRegistered,IsPackageLoaded.
Limitations / Cons
-
No hot-reloading,
Bootstrapis a one-shot call. Reloading a module requires a full game restart. This is intentional for predictability but worth knowing. -
No circular dependency detection, if module A’s
InitcallsLoader:Get('B')and B’sInitcallsLoader:Get('A'), you’ll get a silent nil on one side. Structure your Init calls to avoid this. -
Linear Init order, Init runs in load order (filesystem traversal), not priority order. If you need a specific Init ordering, use Setup for the early work and Init only for wiring.
-
Client/Server split is manual, there’s no shared module type that automatically runs on both sides; you’d place shared logic in a package instead.
