Scaffolding
tools/scaffold.py generates services, controllers, screens, components and the data layer in the shape the workspace expects, and tools/catalog.py generates the capability catalog.
tools/scaffold.py writes new game files from templates and registers them, so every system starts in the same shape: the header comment the catalog reads, the Name CK010 checks, the registry entry, and a spec. It never overwrites a file. tools/catalog.py goes the other way: it reads the header of every framework module and writes docs/agents/Catalog.md, the list agents search before building anything. Both are plain Python with no dependencies beyond the standard library (the scaffold runs StyLua when it can find it). Game code written after scaffolding follows Services and controllers, Components and Screens.
Commands
Section titled “Commands”python tools/scaffold.py data # DataService + Data/Profile.luau + Data/Products.luaupython tools/scaffold.py service Round --deps DataService # Services/RoundService.luau + Registry + specpython tools/scaffold.py controller Camera # Controllers/CameraController.luau + Registry + specpython tools/scaffold.py screen Hud # src/Client/Game/Screens/Hud.luaupython tools/scaffold.py component Door # src/Server/Game/Components/Door.luaupython tools/scaffold.py component Beacon --client # src/Client/Game/Components/Beacon.luau| Subcommand | Arguments | Creates | Updates |
|---|---|---|---|
service |
<Name> [--deps A,B] |
src/Server/Game/Services/<Name>Service.luau, src/Server/Game/Tests/<Name>Service.spec.luau |
src/Server/Game/Registry.luau |
controller |
<Name> [--deps A,B] |
src/Client/Game/Controllers/<Name>Controller.luau, src/Client/Game/Tests/<Name>Controller.spec.luau |
src/Client/Game/Registry.luau |
screen |
<Name> |
src/Client/Game/Screens/<Name>.luau |
nothing |
component |
<Name> [--client] |
src/Server/Game/Components/<Name>.luau, or src/Client/Game/Components/<Name>.luau with --client |
nothing |
data |
none | Services/DataService.luau, Data/Profile.luau, Data/Products.luau under src/Server/Game, each only if absent |
src/Server/Game/Registry.luau (DataService first) |
Two options belong to the tool itself and go before the subcommand:
| Option | Effect |
|---|---|
--root DIR |
Repository root to write into. Default: the checkout that contains the script. |
--no-format |
Skip StyLua on the written files. |
$ python tools/scaffold.py service Shop --no-formatusage: scaffold.py [-h] [--root ROOT] [--no-format] {service,controller,screen,component,data} ...scaffold.py: error: unrecognized arguments: --no-format
$ python tools/scaffold.py --no-format service ShopThe exit status is 0 on success (and when data finds nothing to do), 1 when the scaffold refuses, and 2 on a usage error.
Names must match ^[A-Z][A-Za-z0-9]*$. service and controller append Service or Controller when the name lacks it, so Round and RoundService produce the same file. The scaffold refuses:
| Input | Message |
|---|---|
service shop |
scaffold: 'shop' is not PascalCase (expected something like ShopService) |
service Service |
scaffold: 'Service' is not a usable module name |
screen Vector3 |
scaffold: 'Vector3' is not a usable module name (Roblox datatype names such as CFrame, Color3, Instance, Enum, UDim2 and Vector3 are reserved) |
| A name whose file exists | scaffold: refusing to overwrite existing src/Server/Game/Services/ShopService.luau |
The overwrite check covers every file a command would create, and for services and controllers also the folder form (Services/ShopService/init.luau). It runs before anything is written, so a refused command leaves the tree untouched. A missing Registry.luau stops the command with src/Server/Game/Registry.luau not found: the game skeleton is missing (see AGENTS.md).
Dependencies and registry placement
Section titled “Dependencies and registry placement”--deps takes a comma-separated list. Each entry must be PascalCase and resolves like this:
- If the name as typed is already required in the registry, it is used as typed.
- Otherwise, if the suffixed name (
DatabecomesDataService) is required in the registry or its module file exists, the suffixed name is used. - Otherwise the name is kept as typed.
Any resolved name the registry does not require gets a note: <Name> is not required in src/Server/Game/Registry.luau; make sure something in the Registry registers it (ServiceManager errors at boot on an unregistered dependency).
So --deps Data,Round becomes Dependencies = { "DataService", "RoundService" } once both exist, and a kit dependency such as --deps RoundKit stays "RoundKit" with the note, because kit entries are not require(script.Parent.Services.X) lines. A system cannot depend on itself (<Name> cannot depend on itself), and duplicates collapse.
The new require(script.Parent.Services.<Name>) goes on the line after the last of its dependencies that the registry lists, or at the end when none are listed. data puts DataService first. When the registry already requires the name, it is left unchanged with a note. Boot order does not depend on registry order (the ServiceManager sorts by name and Dependencies); the placement only keeps the file readable. If the registry table is not written one entry per line, the scaffold rewrites it that way.
What the output looks like
Section titled “What the output looks like”$ python tools/scaffold.py service Shop --deps Data,Roundcreated src/Server/Game/Services/ShopService.luaucreated src/Server/Game/Tests/ShopService.spec.luauupdated src/Server/Game/Registry.luau (ShopService after RoundService)next steps: 1. Fill in the header comment and init/start/stop in src/Server/Game/Services/ShopService.luau. Channel handles are on self.Net (Game/init.luau injects them; declare channels in src/Shared/Game/Channels.luau). 2. Replace the placeholder test in src/Server/Game/Tests/ShopService.spec.luau with behavior tests. 3. Run python tools/check.py until it prints CHECK PASSED.screen prints the ScreenGui authoring rules as next steps, component prints the tagging steps, and data prints where to set defaults and grants. Every command ends with the python tools/check.py step.
After writing, the tool runs StyLua on every created and updated file, using .tools/bin/stylua (installed by python tools/setup.py) or one on PATH, with the repository’s stylua.toml. When StyLua is missing it prints note: stylua not found; run python tools/setup.py, then python tools/check.py --fix and leaves the files as generated.
Generated files
Section titled “Generated files”Templates live in tools/scaffold_templates/*.luau.tmpl. {{Name}}, {{DependenciesLiteral}} and {{Folder}} are the only placeholders, and a template with a leftover placeholder is refused.
Service and controller
Section titled “Service and controller”--!strict-- ShopService: <one line: what this service owns>.
local ShopService = { Name = "ShopService", Dependencies = { "DataService", "RoundService" },}
function ShopService.init(self: any, kernel: any) self.Kernel = kernelend
function ShopService.start(_self: any) end
function ShopService.stop(_self: any) end
return ShopServiceA controller is the same template under Controllers/ with the Controller suffix. Replace the placeholder header: tools/catalog.py does not read game modules, but reviewers and agents do, and AGENTS.md requires a one-line -- Name: what it owns. header on every file.
--!nonstrictlocal ShopService = require(script.Parent.Parent.Services.ShopService)
return function(T) local describe, it, expect = T.describe, T.it, T.expect
describe("ShopService", function() it("declares a matching Name", function() expect(ShopService.Name).toBe("ShopService") end) end)endThe spec runs on every Play in Studio before the kernel boots (see The Game layer). Screens and components get no spec: put their rules in plain modules and test those.
Screen
Section titled “Screen”The screen template carries the ScreenGui authoring rules in its header and returns a new(context, gui) constructor with Modal = false, StartsOpen = false, and open, close and destroy that toggle gui.Enabled and destroy a scope. Screens shows it in full.
Component
Section titled “Component”--!strict-- Door component: behavior for instances tagged CKMount with MountType "Door".
return function(kernel: any, _instance: Instance, _attributes: { [string]: any }): (() -> ())? local Scope = kernel:createScope() return function() Scope:destroy() endenddata creates the persistence layer once per game:
--!strict-- DataService: owns player persistence (DataDriver) and developer product receipts.-- Other services read and change profile data through session.Profile.Data only.
local ServerScriptService = game:GetService("ServerScriptService")
local DataDriver = require(ServerScriptService.ChloeKernelServer.Data)local Products = require(script.Parent.Parent.Data.Products)local Profile = require(script.Parent.Parent.Data.Profile)local Receipts = require(ServerScriptService.ChloeKernelServer.Commerce.Receipts)
local DataService = { Name = "DataService",}
function DataService.init(self: any, kernel: any) self.Kernel = kernel self.Driver = DataDriver.new(Profile :: any) self.Driver:attach(kernel) self.Receipts = Receipts.attach(kernel, { GetProfile = function(player: Player) local Session = kernel:getSession(player) return Session and Session.Profile end, }) for ProductId, Grant in Products do self.Receipts:onProduct(ProductId, Grant) endend
function DataService.start(_self: any) end
function DataService.stop(_self: any) end
return DataService--!strict-- Player profile for DataDriver. Change Defaults, SchemaVersion, Migrations and Validate together.-- Never reset progress for invalid data; add a migration before bumping SchemaVersion.-- Validate detects corrupt records: check types and shape, not game rules. A save it rejects is-- refused (Data.SaveRefused) and progress stays unsaved until the data passes again.
local RunService = game:GetService("RunService")
return { Name = "PlayerData", Defaults = { Coins = 0, }, SchemaVersion = 1, Migrations = {}, Validate = function(data: any): boolean return type(data.Coins) == "number" end, -- Studio has no DataStores unless the place is published with API access enabled. -- Delete this line to test real saves in Studio. Backend = if RunService:IsStudio() then "Memory" else "DataStore",}--!strict-- Developer product grants: [productId] = function(session, receiptInfo).-- Grants must not yield and may only change session.Profile.Data; Receipts saves before acknowledging.
return {} :: { [number]: (session: any, receiptInfo: any) -> () }Services that read profile data then declare Dependencies = { "DataService" }. DataDriver and Receipts document the profile fields and grant rules. Validate checks types and shape only: a game rule there (Coins >= 0) refuses every save once a bug breaks it.
The capability catalog
Section titled “The capability catalog”docs/agents/Catalog.md lists every framework module with its require path, the side it runs on and one sentence about what it does. Agents read it in step 2 of the agent workflow to map features to modules. It is generated, never edited by hand.
python tools/catalog.py # rewrite docs/agents/Catalog.mdpython tools/catalog.py --check # exit 1 with a diff summary when the file is stalepython tools/catalog.py --stdout # print the catalog instead of writing it--root DIR points it at another checkout. The generator:
- Walks every
*.luauundersrc/Shared/ChloeKernelandsrc/Server/ChloeKernelServer, skippingTests,TestKit,BenchesandSimulationfolders and any file with a dot in its base name (specs). - Builds each module id from its path, with
init.luaustanding for its folder, and the require path by prefixingReplicatedStorageorServerScriptService. - Reads the header comment: the comment lines after any
--!directives, up to the first blank comment line. It strips a leadingName:orNameplus em dash, and keeps the first sentence, cut at 200 characters. - Marks the side as
SharedorServerby folder, orClientfor the shared modules listed in itsCLIENT_ONLYset (AudioKit, CameraKit, InputDriver, the client halves underNet, and others). - Assigns an area from
AREA_MAP, where the first matching pattern wins: Core, Networking, Persistence and live ops, Commerce, Gameplay systems, Genre kits, Presentation (client), Debug and diagnostics. A module that matches nothing lands in an “Other” section and the tool printscatalog: <path> has no area; add it to AREA_MAP in tools/catalog.py.
At 0.9.1 with the Unreleased fixes the catalog has 142 modules in eight areas. A module’s catalog line is only as good as its header, which is why AGENTS.md requires every file’s second line to be -- Name: what it owns. Game modules in src/*/Game are not cataloged.
python tools/check.py runs python tools/catalog.py --check as its catalog step (quick and full), so a framework change that edits a header or adds a module fails the check until the catalog is regenerated. The scaffold has its own tests in tools/test_scaffold.py, run by the check’s tooling step. See Check.