A strictly and fully typed, tested Luau module framework for Roblox, inspired by Sleitnick's Knit and by Featherfall's Quill. Modules are much like Knit's services and controllers: each has an Init and Start lifecycle, and more lifecycles can be added with the Lifecycles extension.
The core is intentionally small — it loads modules, runs the Init/Start boot barrier, and fires hooks. And like Quill, everything else (Roblox event lifecycles, dependency injection, etc.) lives in extensions that attach to those hooks. The core has no knowledge of RunService events, players, or dependency graphs.
| Package | Description |
|---|---|
chief/core |
The framework core: loads modules, runs the Init/Start barrier, and fires hooks for extensions. |
chief/lifecycles |
Binds events (PlayerAdded, Heartbeat, custom) to module methods. |
chief/dependencies |
Dependency injection with transitive loading and topological Init ordering. |
chief/traits |
Binds per-instance behavior to CollectionService tags, with typed attribute and child contracts. |
chief/bin |
A small cleanup container: connections, instances, threads, and functions, emptied in reverse insertion order. |
Install with LPM:
lpm add chief/core
lpm add chief/lifecycles # optional
lpm add chief/dependencies # optional
lpm add chief/traits # optional
lpm add chief/bin # optional; standalone cleanup container, also used by traits
lpm iChief is LPM-first: packages are published to LPM primarily. Pesde is also supported. If you'd like Wally support, open an issue.
A bootstrap script creates a Chief instance, adds modules and extensions, then starts:
--!strict
local Chief = require('./roblox_packages/core')
local Dependencies = require('./roblox_packages/dependencies')
local Lifecycles = require('./roblox_packages/lifecycles')
Chief.new()
:AddModules(script.Modules:GetChildren())
:AddExtensions({
Dependencies.new(),
Lifecycles.new({
Lifecycles.PlayerAdded,
Lifecycles.Heartbeat,
}),
})
:Start()A module is a plain table with optional Init and Start methods:
--!strict
local DataModule = {}
function DataModule.Init (self: Self)
-- Runs synchronously, in order, before any module's Start. Yielding here errors.
end
function DataModule.Start (self: Self)
-- Spawned concurrently after every module's Init has completed.
end
type Self = typeof(DataModule)
return DataModuleChief:Start() runs the following phases in order:
PreLoad → Load → PostLoad → PreInit → Init → PostInit → PreStart → Start → PostStart
- Every module's
Initruns synchronously and in order (the "init barrier" — yielding duringIniterrors). - Every module's
Startis spawned concurrently afterward. - Modules are sorted by
Priority(higher first); ties break by insertion order.
Extensions attach behavior through two hooks:
onPhase(chief, phase, entries)— fired at each boot phase with every entry loaded so far.onModule(chief, event, entry)— fired per module as it isLoaded,Initialized, andStarted.
During PostLoad, extensions may call chief:LoadModule to pull in additional modules; loading closes after PostLoad. Extensions may reorder entries in place during PostLoad or PreInit — after PreInit the order is final.
See the core README for the full extension contract.
This repository is a pesde workspace (workspace_members = ["packages/*"]). CI runs each check as its own job, so a red badge points at the step that broke.
- Format: StyLua (
stylua.toml) - Lint: selene (
selene.toml) - Typecheck: luau-lsp with the new solver (
.luaurc), covering the packages and the tests —packages/traitsderives its bound-object types withtype function, which only the new solver understands - Test: specs under
tests/, executed in real Roblox by CI via the Open Cloud Luau Execution API roblox_packages/andpesde.lockare generated and gitignored — never edit them.
Specs live in tests/ as *.spec ModuleScripts and run on a small in-repo runner, tests/Runner.luau, which exposes a Jest-shaped API:
--!strict
local Runner = require('./Runner')
local describe = Runner.describe
local expect = Runner.expect
local it = Runner.it
describe('addition', function ()
it('adds', function ()
expect(1 + 1).toBe(2)
end)
end)
return {}Matchers: toBe, toBeTruthy, toBeFalsy, toBeNil, toContain, and toThrow('BadArgument') for Chief's prefixed errors. beforeEach/afterEach nest with describe, and a test body may yield.
Chief uses its own runner rather than TestEZ (archived, and its injected globals force --!nocheck on specs) or Jest Lua (its sandbox rejects require-by-string, which pesde's generated shims depend on, so only dependency-free packages are requirable under it). The runner loads specs with a plain require, so every package is testable, and it exports its API instead of injecting globals, so specs typecheck under --!strict.
To run the suite locally, against the same Open Cloud path CI uses:
rojo build test.project.json --output test-place.rbxl
ROBLOX_API_KEY=... ROBLOX_UNIVERSE_ID=... ROBLOX_PLACE_ID=... \
python3 scripts/upload_and_run_task.py test-place.rbxl tasks/run-tests.luauThe API key needs the universe.place:write and universe.place.luau-execution-session:write scopes. Each run uploads a new saved version of the test place.
MIT